Federation and the shell
Your module ships as a Module Federation remote. Building OS is the host: it owns the page, the React that renders it, and the router your routes mount into. It loads your remoteEntry.js at runtime and mounts your ./Shell.
That arrangement is what lets you deploy without a shell rebuild. It also means your build has to cooperate with a React it does not own — and the failures when it doesn't are invisible in your own CI, because standalone builds have no host to conflict with. This page is the set of rules that keeps that from happening, and the CI check that can see it.
Scaffolded from 1.10.0 onward
npx @tv/extension-sdk init-module writes all of this for you — the shared map, the alias, the shim file, and a ci.yml that runs the smoke. If your module was scaffolded on 1.10.0 or later you already have it; check anyway, and read on if you are upgrading an older module or hand-rolling the config.
One expose, and it is called ./Shell
exposes: {
'./Shell': './src/Shell.tsx',
},The shell resolves exactly that name. Anything else loads nothing and reports nothing useful. Gate it in CI:
npx @tv/extension-sdk check-exposes module-manifest.json --config=./vite.config.tsThe checker looks in frontend/ first, then the repo root.
The shared scope
shared is the list of libraries you want to take from the host instead of bundling your own copy. React must be on it — two Reacts on one page is not a performance problem, it is a crash.
shared: ['react', 'react-dom', 'react-router', 'react-router-dom'],That is what tv-sdk init-module generates. react-router and react-router-dom are there so that <Routes>, <Link> and useNavigate() inside your Shell see the <BrowserRouter> Building OS mounted; a remote that bundles its own router gets its own context and throws. Share both or neither — react-router-dom re-exports react-router.
What the options really do
Building OS and its modules use @originjs/vite-plugin-federation, and much of what you will find written about Module Federation — including in older module repositories — describes webpack's implementation, not this one. Checked against the plugin's shipped code (1.4.1):
| Option | In this plugin |
|---|---|
requiredVersion | The only one that does anything. Unset, or false: take whatever copy the host provides, whatever its version. Set to a range: take the host's copy only if it satisfies the range, otherwise fall back to your own bundled copy — silently. |
singleton | Ignored. Commented out of the plugin's types; absent from its runtime. |
eager | Ignored, likewise. |
strictVersion | Ignored, likewise. |
So the array form above and the long form older modules carry — { singleton: true, eager: true, requiredVersion: false, strictVersion: false } — behave identically. Prefer the array: it does not advertise guarantees the plugin does not give.
The practical consequence is that nothing enforces a version match. Leaving requiredVersion unset is right — the shell upgrades on its own schedule and you do not want to be pinned to it — but it also means a module built against React 18 will run on the shell's React 19 without complaint at build time, and fail at render. Setting requiredVersion does not fix that either: on a mismatch the plugin quietly loads your bundled copy, which is the two-Reacts crash again. The guard is the shell-integration smoke in CI (below), which mounts your built remote against the shell's real versions.
A shared name that is not in package.json fails the build
Could not resolve entry module "zustand". The plugin emits every shared name as a build entry — the fallback copy used when no host provides one — so the package has to be installed. This has nothing to do with eager (it happens with the array form too). Add the entry when you add the dependency, not before.
The use-sync-external-store alias — required
This one is not optional and it is not obvious.
react-i18next 16 and above depend on the CJS use-sync-external-store/shim, whose internals do require('react'). The federation plugin rewrites import declarations into the shared scope; it cannot rewrite a require. So Rollup resolves that require against the real react package and your remote ships a second, complete React. The first hook that runs through it under the host's render dies:
Cannot read properties of null (reading 'useSyncExternalStore')The second copy's dispatcher is null, because the host's React owns the render. Any dependency that CJS-requires React does this — react-i18next is simply the one that reached the fleet first.
The fix is a local ESM stub plus an alias. The stub, verbatim:
// 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 };// vite.config.ts
resolve: {
alias: {
'use-sync-external-store/shim': path.resolve(
__dirname,
'./src/shims/use-sync-external-store-shim.ts',
),
},
},A one-line re-export does not work
export { useSyncExternalStore } from 'react' looks equivalent and is not. The federation plugin rewrites import declarations only; a re-export declaration falls through to the plain resolver, lands on the bundled CJS React, and reintroduces the exact defect the stub exists to remove. Import first, export second.
Keep the alias even if you share i18n (below). It guards the fallback path: when the host declines to share — a version-scope mismatch, an older shell — your remote falls back to its own copy, and without the alias that fallback crashes instead of degrading.
i18n: share both, or share neither
i18nextandreact-i18nextgo insharedboth or neither. Never one of the pair.
Two models are sanctioned. Pick one deliberately.
Model 1 — bundled-isolated (the default)
Neither library is shared. Your module owns both copies and its own i18next instance in src/i18n.ts, and follows the shell's language through resolveLocale() on @tv/extension-sdk/i18n rather than through a shared instance. This is what init-module scaffolds and what tv-module-example runs (private repository; a copy is provided on request — developers@tango.vision).
Choose it unless you specifically need the shell's live language switch to reach your module without a reload.
Model 2 — shared-instance pair
i18next and react-i18next are both in shared, and your i18n layer attaches to the host's instance instead of initialising its own: it registers its catalogues under its own namespace with addResourceBundle, and guards init() behind isInitialized.
if (!i18n.isInitialized) {
void i18n.use(initReactI18next).init({ resources: {}, lng: detectLanguage() /* … */ });
}
// Registration sits OUTSIDE the guard: when the host owns the instance,
// init() never runs here, and an init({ resources }) would register nothing.
i18n.addResourceBundle('en', NS, en, true, true);
i18n.addResourceBundle('ru', NS, ru, true, true);Copy tv-module-bim's frontend/src/i18n/index.ts — ask us for it, the repository is private — and do not improvise the guard. The ordering above is the whole trick and it is easy to get subtly wrong.
The banned shape, and why
Sharing react-i18next while keeping a module-local i18next is the "obvious fix" for the crash above, and it is worse than the crash.
Every module initialises through the react-i18next default instance — i18n.use(initReactI18next) sets a module-global default inside whichever copy the caller imported. Share only react-i18next, and your initReactI18next runs against the host's copy and replaces the default instance for the shell and every other module on the page.
The result is the worst possible symptom: your module renders correctly while the shell around it degrades to raw i18n keys (structure.title, structure.selectBuilding, …). With several modules loaded, last one to initialise wins the whole page. It looks like a shell bug, from inside a module that looks healthy.
What this cost, once
On 2026-08-27, Dependabot majors of react-i18next (15 → 17) were merged across eleven module repositories in one batch. Every ordinary signal was green: install, typecheck, tests, image build, image push. Every one of the eleven crashed on mount inside the shell, and some stayed broken for two days. All were reverted the same day.
Nothing in a standalone module's CI could have caught it, because the defect only exists when the remote's imports resolve against a host-provided shared scope. That is what the check below is for.
The shell-integration smoke
shell-smoke builds your real remoteEntry.js, constructs the shared scope the way the shell's federation runtime does, and mounts your ./Shell under the host's React in jsdom. It then asserts four things: the expose loads, it renders without an error-boundary hit, it renders something at all, and host-global state survived your initialisation — that last one is the i18n-clobber check.
Wire it into your frontend checks job, after the build:
- 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: warnIt must run after the build — it mounts dist/assets/remoteEntry.js, not your source. mode is warn (report-only) across the fleet today and will be armed to error once every default branch passes.
What a green run does not prove
- jsdom is not a browser. Canvas, layout and other missing APIs can fail here and work in Chrome, or pass here and break on a stubbed API.
- Network imports are stubbed, not fetched. An
import('https://cdn…')is replaced with an empty module and logged. The smoke cannot tell you whether CDN-loaded code works — vendor it instead. - One expose per run. Only
./Shellunless you pass others. - The registry is a third source of remotes. The shell also loads remotes from URLs recorded in the platform registry; the smoke says nothing about those.
Upgrading a shared runtime dependency
A semver-major bump of anything in the shell's shared scope — react, react-dom, react-router, react-router-dom, @tanstack/react-query, zustand, i18next, react-i18next, react-oidc-context, oidc-client-ts — can only break inside the shell, which is exactly where your CI cannot see it.
Merge one of those with the smoke present and green, one repository at a time. Never as a synchronized batch across modules: that is the shape of the 2026-08-27 incident, and it converts one debuggable failure into eleven simultaneous ones.
→ Next: Testing