Skip to content

fix(runtime): isolate differently optimized runtime instances - #5145

Open
tonoizer wants to merge 7 commits into
module-federation:mainfrom
tonoizer:fix/debug-constructor-isolation
Open

tonoizer wants to merge 7 commits into
module-federation:mainfrom
tonoizer:fix/debug-constructor-isolation

Conversation

@tonoizer

@tonoizer tonoizer commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Description

Two bundles that each ship their own copy of @module-federation/runtime can share one page. When they were built with different optimizations, one could end up running on the other's runtime instance and lose features it was built with. Two paths caused this:

  • createInstance constructed the class stored in __FEDERATION__.__DEBUG_CONSTRUCTOR__. In debug mode, the last bundle to load sets that global, so a full host could get an instance built with disableRemote. createInstance now constructs its own imported ModuleFederation. The global is still set, and code that wants it can construct it directly.
  • init reused the first global instance with a matching build id, or a matching name and version, without checking what that instance could do. It now reuses an instance only when its capabilities match the caller's: remote loading, shared loading, snapshot plugins, and the build target. The build target matters because a web-target build loads entries with the DOM loader and ships a loadScriptNode stub, so a Node runtime reusing its instance cannot load remotes.

The capabilities are a string on each instance, for example remote,shared,snapshot,web. Other bundles read it from the instance rather than from its constructor, so a runtime that composes its capabilities per instance can describe itself.

Compatibility

  • Updated runtimes do not reuse instances created by older runtimes, since those have no capabilities to compare. Older runtimes keep their original lookup and can still reuse an updated instance with the same identity.
  • Matching capabilities do not guarantee that two different runtime package versions are compatible. This PR does not change that.
  • Code that relied on __DEBUG_CONSTRUCTOR__ to replace the class createInstance builds must now construct that class itself.

Tests

packages/runtime/__tests__/optimized-runtimes.spec.ts builds independent runtime bundles from source with their own flags, serves a real HTTP container and manifest, and runs each scenario in a fresh process. The eleven cases cover:

  • both registration orders, with distinct names and with the same identity;
  • matching and different build ids, and repeated init;
  • compatible reuse that keeps shared state, and createInstance staying fresh;
  • disabled sharing, disabled snapshot plugins, and a remote-capable build without snapshot plugins;
  • a web-target runtime next to a Node runtime with the same identity.

Each capability has a case that fails without it. The target case failed before the capability string included it, with document is not defined.

Commands, on Node 24:

  • pnpm --filter @module-federation/runtime-core exec rstest run --include '**/__tests__/*.spec.ts' passes 126 tests.
  • pnpm --filter @module-federation/runtime exec rstest run --include '**/__tests__/*.spec.ts' passes 103 tests, including the 11 above.
  • pnpm --filter @module-federation/runtime-core --filter @module-federation/runtime run lint passes.
  • tsc on both packages reports only the two utils/preload.ts errors that main already has.

Browser and e2e jobs were not run locally.

Related

The composed-runtime stack for RFC #5128 changes the same lookup (#5135 moves it into runtime/src/instance.ts). There, a composed instance's capabilities come from what its bundle passes in, so the stack needs to set this per-instance string from those capabilities. That follow-up is tracked on the stack.

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

Checklist

  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have updated the documentation.

@changeset-bot

changeset-bot Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5978eed

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

This PR includes changesets to release 48 packages
Name Type
@module-federation/runtime Patch
@module-federation/runtime-core Patch
@module-federation/devtools Patch
@module-federation/dts-plugin Patch
@module-federation/esbuild Patch
@module-federation/metro Patch
@module-federation/modern-js-v3 Patch
@module-federation/modern-js Patch
@module-federation/nextjs-mf Patch
@module-federation/node Patch
@module-federation/observability-plugin Patch
@module-federation/playground Patch
@module-federation/retry-plugin Patch
@module-federation/runtime-tools Patch
@module-federation/webpack-bundler-runtime Patch
@module-federation/bridge-react Patch
@module-federation/bridge-vue3 Patch
website-new Patch
@module-federation/metro-plugin-rnc-cli Patch
@module-federation/metro-plugin-rnef Patch
@module-federation/metro-plugin-rock Patch
shared-tree-shaking-with-server-host Patch
shared-tree-shaking-with-server-provider Patch
@module-federation/rsbuild-plugin Patch
@module-federation/rstest Patch
node-dynamic-remote-new-version Patch
node-dynamic-remote Patch
@module-federation/enhanced Patch
@module-federation/rspack Patch
@module-federation/inject-external-runtime-core-plugin Patch
@module-federation/rspress-plugin Patch
remote5 Patch
remote6 Patch
@module-federation/storybook-addon Patch
shared-tree-shaking-no-server-host Patch
shared-tree-shaking-no-server-provider Patch
@module-federation/cli Patch
create-module-federation Patch
@module-federation/error-codes Patch
@module-federation/managers Patch
@module-federation/manifest Patch
@module-federation/sdk Patch
@module-federation/third-party-dts-extractor Patch
@module-federation/treeshake-frontend Patch
@module-federation/treeshake-server Patch
@module-federation/bridge-react-webpack-plugin Patch
@module-federation/bridge-shared Patch
@module-federation/utilities 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

@pkg-pr-new

pkg-pr-new Bot commented Sep 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

@module-federation/devtools

pnpm add https://pkg.pr.new/@module-federation/devtools@97034d9

@module-federation/cli

pnpm add https://pkg.pr.new/@module-federation/cli@97034d9

create-module-federation

pnpm add https://pkg.pr.new/create-module-federation@97034d9

@module-federation/dts-plugin

pnpm add https://pkg.pr.new/@module-federation/dts-plugin@97034d9

@module-federation/enhanced

pnpm add https://pkg.pr.new/@module-federation/enhanced@97034d9

@module-federation/error-codes

pnpm add https://pkg.pr.new/@module-federation/error-codes@97034d9

@module-federation/esbuild

pnpm add https://pkg.pr.new/@module-federation/esbuild@97034d9

@module-federation/managers

pnpm add https://pkg.pr.new/@module-federation/managers@97034d9

@module-federation/manifest

pnpm add https://pkg.pr.new/@module-federation/manifest@97034d9

@module-federation/metro

pnpm add https://pkg.pr.new/@module-federation/metro@97034d9

@module-federation/metro-plugin-rnc-cli

pnpm add https://pkg.pr.new/@module-federation/metro-plugin-rnc-cli@97034d9

@module-federation/metro-plugin-rnef

pnpm add https://pkg.pr.new/@module-federation/metro-plugin-rnef@97034d9

@module-federation/metro-plugin-rock

pnpm add https://pkg.pr.new/@module-federation/metro-plugin-rock@97034d9

@module-federation/modern-js

pnpm add https://pkg.pr.new/@module-federation/modern-js@97034d9

@module-federation/modern-js-v3

pnpm add https://pkg.pr.new/@module-federation/modern-js-v3@97034d9

@module-federation/native-federation-tests

pnpm add https://pkg.pr.new/@module-federation/native-federation-tests@97034d9

@module-federation/native-federation-typescript

pnpm add https://pkg.pr.new/@module-federation/native-federation-typescript@97034d9

@module-federation/nextjs-mf

pnpm add https://pkg.pr.new/@module-federation/nextjs-mf@97034d9

@module-federation/node

pnpm add https://pkg.pr.new/@module-federation/node@97034d9

@module-federation/observability-plugin

pnpm add https://pkg.pr.new/@module-federation/observability-plugin@97034d9

@module-federation/playground

pnpm add https://pkg.pr.new/@module-federation/playground@97034d9

@module-federation/retry-plugin

pnpm add https://pkg.pr.new/@module-federation/retry-plugin@97034d9

@module-federation/rsbuild-plugin

pnpm add https://pkg.pr.new/@module-federation/rsbuild-plugin@97034d9

@module-federation/rspack

pnpm add https://pkg.pr.new/@module-federation/rspack@97034d9

@module-federation/rspress-plugin

pnpm add https://pkg.pr.new/@module-federation/rspress-plugin@97034d9

@module-federation/rstest

pnpm add https://pkg.pr.new/@module-federation/rstest@97034d9

@module-federation/runtime

pnpm add https://pkg.pr.new/@module-federation/runtime@97034d9

@module-federation/runtime-core

pnpm add https://pkg.pr.new/@module-federation/runtime-core@97034d9

@module-federation/runtime-tools

pnpm add https://pkg.pr.new/@module-federation/runtime-tools@97034d9

@module-federation/sdk

pnpm add https://pkg.pr.new/@module-federation/sdk@97034d9

@module-federation/storybook-addon

pnpm add https://pkg.pr.new/@module-federation/storybook-addon@97034d9

@module-federation/third-party-dts-extractor

pnpm add https://pkg.pr.new/@module-federation/third-party-dts-extractor@97034d9

@module-federation/treeshake-frontend

pnpm add https://pkg.pr.new/@module-federation/treeshake-frontend@97034d9

@module-federation/treeshake-server

pnpm add https://pkg.pr.new/@module-federation/treeshake-server@97034d9

@module-federation/typescript

pnpm add https://pkg.pr.new/@module-federation/typescript@97034d9

@module-federation/utilities

pnpm add https://pkg.pr.new/@module-federation/utilities@97034d9

@module-federation/webpack-bundler-runtime

pnpm add https://pkg.pr.new/@module-federation/webpack-bundler-runtime@97034d9

@module-federation/bridge-react

pnpm add https://pkg.pr.new/@module-federation/bridge-react@97034d9

@module-federation/bridge-react-webpack-plugin

pnpm add https://pkg.pr.new/@module-federation/bridge-react-webpack-plugin@97034d9

@module-federation/bridge-shared

pnpm add https://pkg.pr.new/@module-federation/bridge-shared@97034d9

@module-federation/bridge-vue3

pnpm add https://pkg.pr.new/@module-federation/bridge-vue3@97034d9

@module-federation/inject-external-runtime-core-plugin

pnpm add https://pkg.pr.new/@module-federation/inject-external-runtime-core-plugin@97034d9

commit: 97034d9

@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Report

11 package(s) changed, 32 unchanged.

Package dist + ESM entry

Package Total dist (raw) Delta ESM gzip Delta
@module-federation/dts-plugin 335.7 kB -16 B (-0.0%) 4.7 kB no change
@module-federation/enhanced 818.0 kB +235 B (+0.0%) 672 B no change
@module-federation/playground 16.08 MB -1026 B (-0.0%) 45.8 kB no change
@module-federation/runtime 20.2 kB -149 B (-0.7%) 712 B -12 B (-1.7%)
@module-federation/runtime-core 310.5 kB +21 B (+0.0%) 570 B no change
@module-federation/runtime-tools 7.7 kB -65 B (-0.8%) 142 B no change

Bundle targets

Package Web bundle (gzip) Delta Node bundle (gzip) Delta
@module-federation/cli 2.3 kB -8 B (-0.3%) 2.4 kB -33 B (-1.3%)
@module-federation/core 1.0 kB -3 B (-0.3%) 1.0 kB -35 B (-3.2%)
@module-federation/devtools 30.3 kB -1 B (-0.0%) 30.3 kB -25 B (-0.1%)
@module-federation/enhanced 2.7 kB +12 B (+0.4%) 2.8 kB -46 B (-1.6%)
@module-federation/metro-plugin-rnc-cli 416 B -1 B (-0.2%) 435 B -27 B (-5.8%)
@module-federation/node 9.1 kB +5 B (+0.1%) 9.2 kB -29 B (-0.3%)
@module-federation/runtime 691 B -6 B (-0.9%) 691 B -6 B (-0.9%)

Tree-shakable entrypoints

Package Export Entry gzip Delta Web bundle (gzip) Delta Node bundle (gzip) Delta Gap (node-web) Delta
@module-federation/runtime ./bundler 184 B no change 691 B -6 B (-0.9%) 691 B -6 B (-0.9%) 0 B 0 B

Consumer scenarios

Scenario Web output (gzip) Delta Node output (gzip) Delta Gap (node-web) Delta
Enhanced remoteEntry 22.3 kB -17 B (-0.1%) 23.8 kB -29 B (-0.1%) +1.5 kB -12 B

Total dist (raw): 23.40 MB (-1000 B (-0.0%))
Total ESM gzip: 111.6 kB (-12 B (-0.0%))
Total web bundle (gzip): 253.4 kB (-2 B (-0.0%))
Total node bundle (gzip): 255.6 kB (-201 B (-0.1%))
Tracked ./bundler entry gzip: 563 B (no change)
Tracked ./bundler web bundle (gzip): 4.9 kB (-6 B (-0.1%))
Tracked ./bundler node bundle (gzip): 4.9 kB (-6 B (-0.1%))

Bundle sizes are generated with rslib (Rspack). Package-root metrics preserve the historical report. Tracked subpath exports such as ./bundler are measured separately so ENV_TARGET-driven tree-shaking is visible. Bare imports are externalized to keep package-level sizes consistent, and assets are emitted as resources.

@ScriptedAlchemy

ScriptedAlchemy commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

🤖 Constructing the local class fixes new instances. init still reuses the first global instance with a matching identity, without checking what that instance can do. So registration order decides whether a full runtime gets a remote-capable instance. The distinct-name regression test doesn't cover builds whose identities collide.

I reproduced this at PR head 1e27cdad031ecb890474afd5809269a78190a4c5. The setup is a full runtime and a disableRemote runtime, bundled separately, plus a local HTTP container that returns { value: 'remote-value' }. The two variables below are separate bundle outputs, each built with its own FEDERATION_OPTIMIZE_NO_REMOTE and FEDERATION_BUILD_IDENTIFIER. They are not two imports of one package.

// disabledRuntime: disableRemote=true,  build ID app@1.0.0
// fullRuntime:     disableRemote=false, build ID app@2.0.0
const remote = { name: 'tiny', entry: localHttpRemoteEntry };
const disabled = disabledRuntime.init({ name: 'app', version: '1.0.0', remotes: [remote] });
const full = fullRuntime.init({ name: 'app', version: '1.0.0', remotes: [remote] });

await full.loadRemote('tiny/value');        // actual: throws remote-disabled error
await fullRuntime.loadRemote('tiny/value'); // actual: throws the same error
// Expected for an isolated full runtime: { value: 'remote-value' } from both.

The full runtime's init returns the disabled instance that registered first, so only one global instance exists, with ID app@1.0.0. The reverse order has the opposite problem. The disabled bundle reuses the full instance and loads remote-value. A matching build ID also reuses the first instance. Distinct runtime versions with distinct build IDs keep separate instances.

The same cases fail with the original parent runtime source at 1880a2beee9241f450328464a4a2fc12fab6030d, so the collision already existed before this PR. This PR fixes the constructor failure for distinct names. The name and version fallback after the build ID check in packages/runtime/src/utils.ts:17-37 causes the reuse. As written, the PR does not make differently optimized runtimes safe to run side by side.

Could you either check capabilities when init selects an instance, or narrow the claim to new-instance construction and test that boundary? What identity and capability contract do you intend when builds share a name and runtime version?


🤖 Addressed for updated runtimes in 4b1858e7e. The global instance lookup now requires the same remote, shared, and snapshot capabilities before it checks build ID or name and version. Both initialization orders and matching build IDs now work, and compatible instances are still reused with their shared state. The nine real-bundle tests pass, and the two package suites pass 227 tests with none skipped.

869e211b9 adds the build target to the capabilities. A web-target build loads entries with the DOM loader, so a Node runtime must not reuse its instance. At that head the focused suite has eleven cases, and the two package suites pass 229 tests.

Run the focused tests with:

pnpm --filter @module-federation/runtime exec rstest run --include '**/__tests__/optimized-runtimes.spec.ts'

Mixing old and new runtimes still has limits. I used the previous PR head 1e27cdad as the old runtime. When the old disabled runtime registers first, the updated full runtime still loads remote-value. When the new full runtime registers first, the unchanged older caller reuses the full instance. Updated callers reject instances that have no capabilities, but they can't change how older callers look up instances.

Matching capabilities also don't guarantee that two runtime package versions are compatible. In the reproduction above, options.version is the configured application version, not the installed runtime package version.

@ScriptedAlchemy ScriptedAlchemy changed the title fix(runtime): isolate instance construction from debug globals fix(runtime): isolate differently optimized runtime instances Sep 25, 2026
…icitly

Each scenario now builds its own bundles with named options instead of
deriving build ids and flags from the scenario name. Adds two collisions
the capability check has to catch: a web-target runtime reused by a Node
runtime, and a remote-capable runtime without snapshot plugins.
A web-target build loads remote entries with the DOM loader and ships a
loadScriptNode stub, so a Node runtime that reused its instance could not
load remotes. The capability check now includes the build target, and
the capabilities are a readable string stored on each instance, so a
runtime that composes its capabilities per instance can describe itself.
ScriptedAlchemy added a commit that referenced this pull request Sep 25, 2026
… capabilities

Each instance records runtimeCapabilities in the format main uses (#5145): remote, shared, snapshot, and the platform target. Public init and runtime/compose init skip page-global instances whose string differs from the caller's, and public createInstance constructs its own ModuleFederation class instead of __DEBUG_CONSTRUCTOR__.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants