Private beta — the @z0devs packages are available to design partners while we harden for general availability.Try the live demoRequest access

Troubleshooting

Symptom-first guide to the failures that actually happen — duplicate React, RUNTIME-008, ChunkLoadError, blocked versions, deadlocked module graphs.

Every entry here is a real failure mode with a specific cause. Find your symptom, not your theory.

"Invalid hook call" / "Cannot read properties of null (reading 'useState')"#

Cause: two copies of React in one tree. The remote bundled its own instead of sharing the host's.

Fix: both sides must mark React shared as a singleton:

shared: {
  react: { singleton: true, eager: true, requiredVersion: false },
  "react-dom": { singleton: true, eager: true, requiredVersion: false },
  "react-router-dom": { singleton: true, eager: true, requiredVersion: false },
}

The subtle version: the package roots are shared but the subpath entrypoints aren't. Code compiled with the automatic JSX runtime imports react/jsx-runtime directly, so an unshared subpath resolves to the bundle's own copy. Add these to every participant — host and every remote:

"react/jsx-runtime": { singleton: true, eager: true, requiredVersion: false },
"react/jsx-dev-runtime": { singleton: true, eager: true, requiredVersion: false },
"react-dom/client": { singleton: true, eager: true, requiredVersion: false },

Asymmetric share sets just relocate the duplicate: adding the subpaths to the host alone fixes one remote and breaks another.

Prevent it: z0Shared() / z0Federation() from @z0devs/mf-config emit the full set, so the two sides agree by construction instead of by review.

The library now tells you. @z0devs/mf-core inspects the share scope the runtime actually assembled after every successful remote load and warns with the offending package named:

[@z0devs/mf-core] share-scope preflight: the share scope has "react" but not
"react/jsx-runtime", "react-dom/client". … Add them to `shared` in the HOST's bundler config,
or use `z0Shared()` from @z0devs/mf-config, which includes them.

It runs on success on purpose: a bad share map usually doesn't fail the load — it succeeds and dies on the first hook, inside a React render, with a stack pointing at the remote's component rather than the config responsible. Dev-only by default; force it on in a production build with reportShareScopePreflight({ instance: getInstance(), enabled: true }). diagnoseLoadError additionally translates a recognised Module Federation error into a plain-language cause beside the original.

Check it by hand: Object.keys(__FEDERATION__.__SHARE__) in the console. Note that __FEDERATION__.__SHARE__.react[…].useIn is not a reliable test of "is this remote bound to the host's copy" — useIn records which participants have actually loaded the module, so a remote that shares react-dom correctly still never appears in its useIn if nothing in that remote imports it. Only the host mounts.

Differing patch versions are fine

Module Federation keys the share scope per provider, so a host on react@18.3.1 and a remote on 18.2.0 legitimately show two entries while singleton: true still resolves everyone onto one instance. Only a major split is a real problem — and requiredVersion: false suppresses MF's own warning about it, which is why the preflight reports majors specifically.

If the remote is Vite-built: it also needs import: false on its shares (it binds its own fallback even when the host's copy is unified) and the host must register its var container, not the ESM one. See cross-bundler settings.

RUNTIME-008 — script executed but no container appeared#

Cause: the remote is an ESM container being loaded as a classic script. The runtime can't infer the format from the URL; it must be declared.

Fix, if the remote ships only an ESM container — one manifest edit:

{ "service": { "id": "esm_only_mfe", "remoteEntry": "https://cdn/…/esmRemoteEntry.js", "type": "module" } }

Fix, if it's a Vite remote built with z0Federation({ target: "vite" }) — the opposite. That build emits an ESM container and a var container, and the host must register the var one:

{ "service": { "id": "vite_plugin", "remoteEntry": "https://cdn/…/remoteEntry.js", "type": "global" } }

Setting "module" there points at the ESM container, which evaluates its own module body on import() and self-initializes its share scope before the host can pass its own — you get S is not a function instead. Full detail in Cross-bundler.

Entries pointing at an mf-manifest.json self-describe and don't need type at all.

RUNTIME-015 — container initialization failed#

Cause: the remote's container threw inside init(). Two common reasons, and the error appends the original message, so read the tail:

  • module is not defined — you are serving a Vite remote's dev server. It injects @module-federation/dts-plugin into the entry, which references module. Serve the built output (vite preview, or any static host) for the federated path. Details in Cross-bundler.
  • S is not a function — the host registered the ESM container where a var one was expected. See RUNTIME-008 above for the type field, and note it is "global" for a Vite remote built with z0Federation({ target: "vite" }).

This code only exists on runtime 2.x

@module-federation/error-codes tops out at RUNTIME_008 on 0.8.x and RUNTIME_009 on 0.22.x — RUNTIME_015 was added in 2.x. On an older runtime the same failure surfaces as the bare original message with no code, so match on the tail rather than the number.

ChunkLoadError / "Loading chunk N failed"#

Cause: a code-split chunk of an already-registered remote failed mid-flight — network hiccup, or a deploy that removed the chunk the running page expects.

What the library does: treats it as transient and retries; Webpack evicts failed chunk promises, so a retried loadRemote genuinely refetches. Tune with loadPolicy.

What it can't do: chunks fetched after a remote has mounted are the remote build's concern, and stale-deploy 404s need a host-level "reload once" on ChunkLoadError:

window.addEventListener("unhandledrejection", (event) => {
  if (/ChunkLoadError|Loading chunk/.test(String(event.reason)) && !sessionStorage.getItem("reloaded")) {
    sessionStorage.setItem("reloaded", "1");
    window.location.reload();
  }
});

pnpm install refuses a fresh @z0devs release ("immature version")#

Symptom: right after we ship a release, pnpm install reports versions blocked by minimumReleaseAge — or, in loose mode, warns and auto-writes entries like this into your pnpm-workspace.yaml:

Added 3 entries to minimumReleaseAgeExclude in pnpm-workspace.yaml
(loose mode allowed these immature versions)

Cause: not us and not you — pnpm 11's supply-chain policy quarantines any newly published version for a configurable age, on the theory that a hijacked package's malicious release gets caught within hours. Every fresh @z0devs release is "immature" by definition for its first hours, and on strict settings the install hard-fails.

Fix: exempt the scope's packages rather than lowering your global policy — they come from a private, vendor-controlled registry, not the public firehose the policy is aimed at:

pnpm-workspace.yaml
minimumReleaseAgeExclude:
  - "@z0devs/mf-plugin-kit"
  - "@z0devs/mf-registry"
  - "@z0devs/mf-config"
  # ...plus any other @z0devs package you install

Or simply wait out your configured minimumReleaseAge — the version isn't broken, just young. Loose mode's auto-written exact-version entries also work, but they re-trigger on every release; the name-level entries above don't.

The plugin doesn't appear, and there's no error#

Almost always one of four things, all of which emit a record you're not listening to. Wire onPluginError first, then read the kind:

RecordMeaningFix
manifest-invalidSchema validation failedRun validateManifestEntry on the entry; usually a missing service.remoteEntry
disabledenabled: falseFlip it
version-blockedshellApiVersion doesn't satisfy the shell's apiVersionWiden the range after verifying compatibility, or versionPolicy: "warn" for a rollout window
dependency-unmetA required plugin dependency is absent or out of rangeShip the dependency in the same manifest, or relax the range

<PluginDebugPanel /> shows the same information without any wiring.

CORS: "blocked by CORS policy" on remoteEntry.js#

Cause: the plugin's host isn't sending CORS headers, or only sends them on GET and not on the OPTIONS preflight.

Fix: see Deploying plugins. Verify with:

curl -i -H "Origin: https://app.example.com" \
     -H "Access-Control-Request-Method: GET" \
     -X OPTIONS https://cdn.example.com/plugins/my-plugin/remoteEntry.js

Also check Content-Type: application/javascript (not text/plain), and that a chunk request from a different path isn't missing the same headers.

Mixed content — HTTPS shell, HTTP plugin#

The browser blocks it silently in some configurations. Every production remoteEntry URL must be HTTPS.

Shell boots to a blank screen, no error at all#

Cause: an async module-graph deadlock. The plugin's bridge module top-level-awaits bridge.ready, which waits on shell/sharedStore — and the shell's sharedStore module imports back into a remote. Two async graphs waiting on each other never settle.

Fix: the shell's sharedStore must not import from any remote. Keep it dependency-light: hooks, registries, helpers.

"useShellSharedStore called before the store resolved"#

Cause: a component called the hook before the bridge bound the store, and nothing gated it.

Fix, in order of preference:

  1. await bridge.ready at the top of the bridge module (needs top-level-await support — Rspack yes, Webpack 5 with experiments.topLevelAwait: true).
  2. withCombinedStore(Component) around the federated entry point — needs nothing from the bundler.
  3. <CombinedStoreProvider> around the tree.

Option 2 is the right one for components you publish into the host's component registry, since other MFEs render them outside your provider tree.

process is not defined#

Cause: the bridge reads process.env.IS_STANDALONE and the bundle has no substitution for it.

Fix:

new DefinePlugin({
  "process.env.IS_STANDALONE": JSON.stringify(process.env["IS_STANDALONE"] ?? "false"),
});

Don't "fix" it with a typeof process guard — DefinePlugin substitutes the env expression but leaves typeof process alone, which silently defeats a correctly-inlined flag.

[@z0devs/mf-plugin-kit] missing local stub for "<key>"#

Working as intended. Your standalone stub doesn't cover a value the plugin reads. Add it to createLocalStubContext, and narrow the slice type so the compiler catches the next one:

type Slice = SelectSharedStoreSlice<
  ReturnType<typeof useShellSharedStore>,
  { values: { useAuth: true; contextualApiFetch: true } }
>;

Duplicate nav items / ghost routes after an org switch#

Cause: init registered without returning a cleanup, so a re-mount registers again.

Fix:

useEffect(() => {
  registries.nav.addItem({ id: "home", title: "My Plugin", url: "/my-plugin" });
  return () => registries.nav.removeItem("home");
}, [registries]);

Host-side, NavRegistry.api.clearRemote("my_plugin") wipes a remote's leftovers.

registries.routes is undefined#

Cause: the host didn't enable the router feature.

Fix: features: { router: true } on defineSharedStore, or register through routeRegistryApi directly (prefixing ids yourself).

addNavItem is not a function#

Registries are canonical: addItem / removeItem / getItem / getItems. There are no add<Noun> aliases.

Route registers but navigation does nothing#

Cause: two react-router-dom instances — the plugin registered into a router the host isn't rendering.

Fix: share react-router-dom as a singleton on both sides.

integrity-violated#

The bytes at service.remoteEntry don't match remoteEntryHash. Either the plugin redeployed without updating the manifest, or something served different bytes. Never silence it by dropping the hash — recompute it from the artifact you actually reviewed:

openssl dgst -sha256 -binary remoteEntry.js | openssl base64 -A

Plugin loads, but its env config is stale#

env-config.js was cached. It's the per-deploy config knob — cap it at Cache-Control: public, max-age=60, must-revalidate.

Types don't resolve for shell/sharedStore#

Cause: the plugin's tsconfig doesn't point at the shell's emitted types.

Fix: the shell builds with dts.generateTypes, and the plugin adds:

{ "compilerOptions": { "paths": { "shell/*": ["../shell/dist/@mf-types/shell/*"] } } }

Dynamically-loaded plugin components can't be auto-typed (the remote id is a runtime string) — type those at the call site with BaseRemote<typeof MyPage>.

Onboarding host crashes with a hooks error#

Cause: a hook inside an OnboardingStep predicate. Predicates run inside a short-circuiting find(...) during render, so they'd be called conditionally.

Fix: read reactive state in the init component, capture the values, and let the predicate be a pure function of those captures.

HMR doesn't cross the shell↔plugin boundary#

Known limitation. Live-reload each side independently.

Still stuck?#