Skip to content

fix(micro-frontend): check shared modules before a remote bundle runs - #446

Open
gronxb wants to merge 9 commits into
mainfrom
fix/micro-frontend-shared-module-check
Open

gronxb wants to merge 9 commits into
mainfrom
fix/micro-frontend-shared-module-check

Conversation

@gronxb

@gronxb gronxb commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

A remote bundle reads each shared module through a facade (\0granite:shared:<name>) that throws Shared module <name> is not registered when the host has not registered that module. The facade runs when it is first required:

  • With eager imports, every statically imported facade runs while the bundle loads, so a missing shared module fails the remote at load time.
  • With a bundler that defers imports until their first use, the facade runs only when some code first reads the module. The same error then surfaces in whatever screen touches the module first, after the remote has already rendered.

This PR adds an intro to the Rollipop shared-modules plugin. It lists the shared modules the chunk includes and checks them against __MICRO_FRONTEND__.__SHARED__ before any module runs, so a missing shared module fails the remote while it loads again, regardless of how the bundler evaluates imports.

(function (names) {
  var registry = globalThis.__MICRO_FRONTEND__ && globalThis.__MICRO_FRONTEND__.__SHARED__;
  for (var i = 0; i < names.length; i++) {
    if (registry == null || registry[names[i]] == null) throw new Error('Shared module ' + names[i] + ' is not registered');
  }
})(["date-fns","lodash"]);

Behavior

  • The error message is the same one the facade already throws.
  • Chunks without shared modules get no intro.
  • The check covers every shared module in the chunk. A shared module the bundle includes but only requires on a rarely taken path now also has to be registered when the bundle loads. Before, a missing one failed only when that path ran.
  • Rollipop renders intro before its runtime and module factories, so the check runs before any module.

Tests

  • packages/micro-frontend/src/next/index.spec.ts: the intro lists only the shared modules in the chunk, passes when all are registered, throws for a missing one, and is empty when the chunk has no shared modules.
  • yarn test in packages/micro-frontend (38 files, 252 tests), yarn typecheck, ESLint and Prettier on the changed files pass.

A remote bundle reads each shared module through a facade that throws
`Shared module <name> is not registered` when the host has not
registered it. The facade runs when it is first required. With eager
imports that is while the bundle loads, but a bundler that defers
imports until their first use moves the error into whatever screen
first touches the module.

Add an `intro` to the Rollipop shared-modules plugin that checks every
shared module the chunk includes before any module runs, so a missing
shared module fails the remote while it loads again.
@changeset-bot

changeset-bot Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 99b4c05

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 31 packages
Name Type
@granite-js/micro-frontend Patch
babel-preset-granite Patch
@granite-js/blur-view Patch
@granite-js/brownfield-module Patch
@granite-js/cli Patch
@granite-js/config Patch
@granite-js/cookies Patch
create-granite-app Patch
@granite-js/screen Patch
@granite-js/image Patch
@granite-js/jest Patch
@granite-js/lottie Patch
@granite-js/mpack Patch
@granite-js/native Patch
@granite-js/naver-map Patch
@granite-js/plugin-core Patch
@granite-js/plugin-env Patch
@granite-js/plugin-hermes Patch
@granite-js/plugin-micro-frontend Patch
@granite-js/plugin-router Patch
@granite-js/plugin-rozenite Patch
@granite-js/plugin-sentry Patch
@granite-js/react-native Patch
@granite-js/rollipop Patch
@granite-js/style-utils Patch
@granite-js/utils Patch
@granite-js/video Patch
@granite-js/vitest Patch
@granite-js/deployment-manager Patch
@granite-js/forge-cli Patch
@granite-js/pulumi-aws Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
granite-docs Ready Ready Preview Nov 14, 2026 3:53am UTC

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
@granite-js/deployment-manager

yarn add https://pkg.pr.new/toss/granite/@granite-js/deployment-manager@446.tgz

@granite-js/forge-cli

yarn add https://pkg.pr.new/toss/granite/@granite-js/forge-cli@446.tgz

@granite-js/pulumi-aws

yarn add https://pkg.pr.new/toss/granite/@granite-js/pulumi-aws@446.tgz

babel-preset-granite

yarn add https://pkg.pr.new/toss/granite/babel-preset-granite@446.tgz

@granite-js/blur-view

yarn add https://pkg.pr.new/toss/granite/@granite-js/blur-view@446.tgz

@granite-js/brownfield-module

yarn add https://pkg.pr.new/toss/granite/@granite-js/brownfield-module@446.tgz

@granite-js/cli

yarn add https://pkg.pr.new/toss/granite/@granite-js/cli@446.tgz

@granite-js/config

yarn add https://pkg.pr.new/toss/granite/@granite-js/config@446.tgz

@granite-js/cookies

yarn add https://pkg.pr.new/toss/granite/@granite-js/cookies@446.tgz

create-granite-app

yarn add https://pkg.pr.new/toss/granite/create-granite-app@446.tgz

@granite-js/screen

yarn add https://pkg.pr.new/toss/granite/@granite-js/screen@446.tgz

@granite-js/image

yarn add https://pkg.pr.new/toss/granite/@granite-js/image@446.tgz

@granite-js/jest

yarn add https://pkg.pr.new/toss/granite/@granite-js/jest@446.tgz

@granite-js/lottie

yarn add https://pkg.pr.new/toss/granite/@granite-js/lottie@446.tgz

@granite-js/micro-frontend

yarn add https://pkg.pr.new/toss/granite/@granite-js/micro-frontend@446.tgz

@granite-js/mpack

yarn add https://pkg.pr.new/toss/granite/@granite-js/mpack@446.tgz

@granite-js/native

yarn add https://pkg.pr.new/toss/granite/@granite-js/native@446.tgz

@granite-js/naver-map

yarn add https://pkg.pr.new/toss/granite/@granite-js/naver-map@446.tgz

@granite-js/plugin-core

yarn add https://pkg.pr.new/toss/granite/@granite-js/plugin-core@446.tgz

@granite-js/plugin-env

yarn add https://pkg.pr.new/toss/granite/@granite-js/plugin-env@446.tgz

@granite-js/plugin-hermes

yarn add https://pkg.pr.new/toss/granite/@granite-js/plugin-hermes@446.tgz

@granite-js/plugin-micro-frontend

yarn add https://pkg.pr.new/toss/granite/@granite-js/plugin-micro-frontend@446.tgz

@granite-js/plugin-router

yarn add https://pkg.pr.new/toss/granite/@granite-js/plugin-router@446.tgz

@granite-js/plugin-rozenite

yarn add https://pkg.pr.new/toss/granite/@granite-js/plugin-rozenite@446.tgz

@granite-js/plugin-sentry

yarn add https://pkg.pr.new/toss/granite/@granite-js/plugin-sentry@446.tgz

@granite-js/react-native

yarn add https://pkg.pr.new/toss/granite/@granite-js/react-native@446.tgz

@granite-js/rollipop

yarn add https://pkg.pr.new/toss/granite/@granite-js/rollipop@446.tgz

@granite-js/style-utils

yarn add https://pkg.pr.new/toss/granite/@granite-js/style-utils@446.tgz

@granite-js/utils

yarn add https://pkg.pr.new/toss/granite/@granite-js/utils@446.tgz

@granite-js/video

yarn add https://pkg.pr.new/toss/granite/@granite-js/video@446.tgz

@granite-js/vitest

yarn add https://pkg.pr.new/toss/granite/@granite-js/vitest@446.tgz

commit: 99b4c05

A host prelude imported every shared module with `import * as` and passed the
namespace to registerShared, so each shared package ran while the host bundle
started. With `lazy: true` the prelude registers a loader instead and the
module runs on the first get(). Consumers only call get(), so remotes keep
working unchanged.
@gronxb

gronxb commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator Author

Added lazy host shared modules to this PR (721f6aa, docs in 26736d3 / aae4bf1).

Why
With Rollipop lazy imports (rollipop-dev/rolldown#65), a module behind a re-export stays lazy only when a read of that export is provable. When a namespace object escapes, every re-exported module behind it is evaluated eagerly. The host prelude imports each shared package with import * as and passes the namespace to registerShared, so the whole barrel of every shared package ran while the host bundle started, even when no remote had used it yet.

What

  • shared[name] = { eager: true, lazy: true } registers the module at startup with a loader (function () { return require(name); }) instead of import * as. The module is evaluated on the first get().
  • The registry contract is unchanged: entries are still { get, loaded } and consumers (remote facades, the Rollipop and Mpack resolvers, the NativeComponentRegistry compatibility layer) only call get(). loaded becomes true after the first get(), like a webpack share scope entry.
  • A load that throws is not cached, so the next get() throws again instead of returning a partial module.
  • Opt-in. Without lazy, the prelude is the same as before. lazy is ignored unless eager: true.

Module Federation mapping
This is Module Federation's eager: false (provided, loaded on first use) with a synchronous get(). Granite keeps its existing eager meaning: a module without eager: true is not provided and is resolved from the shared registry, like Module Federation's import: false. Changing eager itself would have broken existing remote configs.

Tests
prelude.spec.ts: a lazy entry does not load at registration, loads once on the first get() and reports loaded, and retries after a throwing load.

…n Rollipop

A lazy shared module was loaded with require(name). Rolldown keeps the barrel
and re-export modules of a required ESM package because its namespace object
has to exist at runtime, so a lazy host bundle grew. The Rollipop prelude now
requires a virtual module that imports the package with `import * as` and
re-exports the namespace, so the package is imported the same way as an eager
registration. Mpack keeps require(name).
@gronxb

gronxb commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator Author

On the question about require() breaking tree-shaking under the shared module tree: checked it with Rolldown (Rollipop format, lazy imports on).

It does happen, at a smaller scale. In our host bundle (about 20 shared packages), the require()-based lazy registration kept 47 extra modules: package barrels (.../index.js), type-only modules (types.ts), and a few classes reachable only through barrels. Hermes bytecode grew by about 0.7%. With import * as, Rolldown binds re-exports straight to their canonical symbols and drops those barrels. Under require(), the ESM graph below the required module is wrapped for lazy initialization, so the barrels stay as real modules.

The ESM-first resolution approach does not address it. These packages already resolve to their ESM builds. The cost comes from the require() boundary, not from CJS builds. I also tried moving the boundary to a tiny virtual module that does import * as ns from 'pkg'; export { ns } and requiring that instead (9c08e0d). It did not help (the wrapping propagates through the virtual module), so it is reverted (a7ef400).

Why lazy stays anyway. On device (Galaxy S24, cold start into a service, 10 runs each, same network conditions), lazy registration made the mono runtime's first paint about 6% faster, and the dual runtime showed no meaningful change. We accept the ~0.7% size cost for now.

What would remove the cost. Register lazy modules through named imports and getters instead of require():

import { A, B } from 'pkg';
registerLazyShared('pkg', () => Object.defineProperties({}, { A: { enumerable: true, get: () => A }, B: { enumerable: true, get: () => B } }));

In a small Rolldown fixture this keeps the same modules as eager registration, evaluates nothing at startup, and evaluates only the modules behind the exports that are actually read. Reads through named imports are statically provable, so Rolldown's lazy imports can defer each re-export on its own. The catch is that the plugin has to know each package's export names at build time. this.load() reports export * as *, so the plugin has to follow star re-exports with this.parse(), and CommonJS packages still need require(). That is a follow-up rather than part of this PR.

…imports in Rollipop

A lazy shared module was loaded with require(name). Rolldown keeps the barrel
and re-export modules of a required ESM package, so a lazy host bundle grew.
The Rollipop prelude now finds the package's export names at build time
(following export *) and registers a facade whose getters read named imports.
Rolldown can prove each read, so the package is tree-shaken like an eager
registration and its modules are evaluated when an export is first read.
The facade has no __esModule, so registration normalizes it like an
import * namespace. CommonJS packages keep require(name) with a warning.
…red facades

Find a lazy shared module's namespace names with the GetExportedNames and
ResolveExport steps: export * never re-exports default, a name two export *
modules provide from different bindings is ambiguous and left out, and an
explicit export hides the same name. Type-only exports are left out. The
facade is shaped like a module namespace object: null prototype, sorted
read-only live exports, a Module tag, and not extensible.
…irst get

Importing a module evaluates its whole graph, including side-effect-only
imports such as import 'pkg' in a remote. The facade read nothing until a
consumer read an export, so a remote that only imported a package for its
side effects never ran it. The first get() now reads every export once, so
the package is evaluated then, as require(name) did, and the named imports
keep its tree-shaking.

This branch was successfully deployed

1 active deployment
Preview — 99b4c051 Deployed Nov 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant