Skip to content

fix(runtime): keep top-level singleton on first init - #5028

Open
ScriptedAlchemy wants to merge 2 commits into
mainfrom
fix/runtime-singleton-first-init
Open

fix(runtime): keep top-level singleton on first init#5028
ScriptedAlchemy wants to merge 2 commits into
mainfrom
fix/runtime-singleton-first-init

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Member

Contract: init() and the default instance

packages/runtime/src/index.ts keeps a module-level default instance (DefaultFederationInstance) that backs the top-level convenience APIs loadRemote, loadShare, loadShareSync, preloadRemote, registerRemotes, registerPlugins, registerShared and getInstance() (called without a finder). This PR makes the contract explicit, documents it in JSDoc on init() / createInstance(), and encodes it in tests:

  • The first successful init() establishes the default.
  • Later init() calls create or reuse named instances and return them, but never redirect the top-level APIs away from the established default.
  • createInstance() never establishes or changes the default.
  • An instance created by createInstance() and later passed through init() with the same name becomes the default only if no default exists yet.
  • getInstance(finder) searches __FEDERATION__.__INSTANCES__ independently of the default (unchanged).

init() is restructured so instance selection happens first and the default is assigned in exactly one place:

export function init(options: UserOptions): ModuleFederation {
  const normalizedOptions = { ...options, id: options.id || '' };
  let instance = getGlobalFederationInstance(options.name, options.version);
  if (instance) {
    instance.initOptions(normalizedOptions);
  } else {
    instance = createInstance(normalizedOptions);
  }
  DefaultFederationInstance ??= instance;
  return instance;
}

Defect this fixes

Previously init() unconditionally overwrote the singleton whenever it created a new instance:

if (!instance) {
  FederationInstance = createInstance(normalizedOptions); // overwrites unconditionally
  return FederationInstance;
}

When a remote entry resolves @module-federation/runtime to the same module copy as the host (typical for Node SSR builds that externalize node_modules), executing the remote container calls init({ name: '<remote>' }) through the bundler runtime. That created a new instance and silently re-pointed the host's top-level API at the remote.

Measured hijack

In a Node repro, after a single loadRemote('repro_remote/Widget'):

  • getInstance().name changed from repro_host to repro_remote
  • a subsequent top-level registerRemotes(...) wrote into the remote instance's empty options.remotes instead of the host's

This breaks any host that refreshes remote definitions at runtime (see #4566).

Tests

New default instance contract block in packages/runtime/__tests__/api.spec.ts. Each test gets a fresh runtime module (rs.resetModules() + dynamic import) and a fresh global instance list (resetFederationGlobalInfo() from @module-federation/runtime-core), so neither the module-level default nor __FEDERATION__.__INSTANCES__ leaks between tests. Covered:

  • first init() establishes the default (getInstance() is null before, the host after)
  • second init() with a different name returns its own instance and does not replace the default
  • repeated init() with the same name reuses the instance and merges options (a remote registered by the second call is visible on the first instance)
  • createInstance() never establishes the default; createInstance() after a default never changes it
  • getInstance(finder) still searches global instances independently of the default
  • every top-level API (registerRemotes, registerShared, registerPlugins, preloadRemote, loadRemote, loadShare) routes to the default: spies on both instances assert the call lands on the default and not on the second init() instance
  • top-level registerRemotes mutates the host's options.remotes, not the later init() instance's (the original regression)
  • a createInstance() instance adopted by init() becomes the default when none exists, and does not when one already exists

The "returns the default instance when no finder is provided" test now reads the current default via getInstance() instead of assuming the latest init wins.

Sanity check: reverting ??= to = in init() fails 4 of the new tests.

pnpm --filter @module-federation/runtime test: 12 files, 103 tests, all passing.

Changeset added for @module-federation/runtime (patch).

🤖 Generated with Claude Code

init() unconditionally re-pointed the module-level FederationInstance that
backs the top-level loadRemote/registerRemotes/registerPlugins/loadShare/
preloadRemote/registerShared/getInstance exports whenever it created a new
instance. When a remote container resolves @module-federation/runtime to the
same copy as the host (typical for Node SSR builds that externalize
node_modules), executing the remote entry calls init({ name: '<remote>' })
and silently hijacks the host's top-level API: getInstance() returns the
remote and registerRemotes() writes into the remote's empty options.remotes.

Make the singleton first-wins in the create branch, mirroring the existing
else branch, while still returning the newly created instance. Add a
regression test and a changeset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8c3df75

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/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/runtime-core 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b23b5730d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/runtime/src/index.ts Outdated
// The top-level API singleton is first-wins: a later init() call (for
// example from a remote container that shares this runtime copy) must
// not re-point loadRemote/registerRemotes/getInstance to its instance.
if (!FederationInstance) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebind the singleton after federation state is reset

When the exported resetFederationGlobalInfo() clears CurrentGlobal.__FEDERATION__.__INSTANCES__ (packages/runtime-core/src/global.ts:114-117) and the same runtime module is subsequently reinitialized, init() creates and globally registers a replacement but this condition preserves the detached old FederationInstance. Consequently, getInstance() and top-level APIs such as loadRemote and registerRemotes continue operating on stale state rather than the instance just returned by init(); before this change, the creation branch rebound the singleton. Treat the singleton as unset when it is no longer present in the global instance registry.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T21:36:44.727093Z 4b23b57 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

@module-federation/devtools

pnpm add https://pkg.pr.new/@module-federation/devtools@8c3df75

@module-federation/cli

pnpm add https://pkg.pr.new/@module-federation/cli@8c3df75

create-module-federation

pnpm add https://pkg.pr.new/create-module-federation@8c3df75

@module-federation/dts-plugin

pnpm add https://pkg.pr.new/@module-federation/dts-plugin@8c3df75

@module-federation/enhanced

pnpm add https://pkg.pr.new/@module-federation/enhanced@8c3df75

@module-federation/error-codes

pnpm add https://pkg.pr.new/@module-federation/error-codes@8c3df75

@module-federation/esbuild

pnpm add https://pkg.pr.new/@module-federation/esbuild@8c3df75

@module-federation/managers

pnpm add https://pkg.pr.new/@module-federation/managers@8c3df75

@module-federation/manifest

pnpm add https://pkg.pr.new/@module-federation/manifest@8c3df75

@module-federation/metro

pnpm add https://pkg.pr.new/@module-federation/metro@8c3df75

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

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

@module-federation/metro-plugin-rnef

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

@module-federation/metro-plugin-rock

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

@module-federation/modern-js

pnpm add https://pkg.pr.new/@module-federation/modern-js@8c3df75

@module-federation/modern-js-v3

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

@module-federation/native-federation-tests

pnpm add https://pkg.pr.new/@module-federation/native-federation-tests@8c3df75

@module-federation/native-federation-typescript

pnpm add https://pkg.pr.new/@module-federation/native-federation-typescript@8c3df75

@module-federation/nextjs-mf

pnpm add https://pkg.pr.new/@module-federation/nextjs-mf@8c3df75

@module-federation/node

pnpm add https://pkg.pr.new/@module-federation/node@8c3df75

@module-federation/observability-plugin

pnpm add https://pkg.pr.new/@module-federation/observability-plugin@8c3df75

@module-federation/playground

pnpm add https://pkg.pr.new/@module-federation/playground@8c3df75

@module-federation/retry-plugin

pnpm add https://pkg.pr.new/@module-federation/retry-plugin@8c3df75

@module-federation/rsbuild-plugin

pnpm add https://pkg.pr.new/@module-federation/rsbuild-plugin@8c3df75

@module-federation/rspack

pnpm add https://pkg.pr.new/@module-federation/rspack@8c3df75

@module-federation/rspress-plugin

pnpm add https://pkg.pr.new/@module-federation/rspress-plugin@8c3df75

@module-federation/rstest

pnpm add https://pkg.pr.new/@module-federation/rstest@8c3df75

@module-federation/runtime

pnpm add https://pkg.pr.new/@module-federation/runtime@8c3df75

@module-federation/runtime-core

pnpm add https://pkg.pr.new/@module-federation/runtime-core@8c3df75

@module-federation/runtime-tools

pnpm add https://pkg.pr.new/@module-federation/runtime-tools@8c3df75

@module-federation/sdk

pnpm add https://pkg.pr.new/@module-federation/sdk@8c3df75

@module-federation/storybook-addon

pnpm add https://pkg.pr.new/@module-federation/storybook-addon@8c3df75

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

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

@module-federation/treeshake-frontend

pnpm add https://pkg.pr.new/@module-federation/treeshake-frontend@8c3df75

@module-federation/treeshake-server

pnpm add https://pkg.pr.new/@module-federation/treeshake-server@8c3df75

@module-federation/typescript

pnpm add https://pkg.pr.new/@module-federation/typescript@8c3df75

@module-federation/utilities

pnpm add https://pkg.pr.new/@module-federation/utilities@8c3df75

@module-federation/webpack-bundler-runtime

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

@module-federation/bridge-react

pnpm add https://pkg.pr.new/@module-federation/bridge-react@8c3df75

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

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

@module-federation/bridge-shared

pnpm add https://pkg.pr.new/@module-federation/bridge-shared@8c3df75

@module-federation/bridge-vue3

pnpm add https://pkg.pr.new/@module-federation/bridge-vue3@8c3df75

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

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

commit: 8c3df75

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Report

8 package(s) changed, 35 unchanged.

Package dist + ESM entry

Package Total dist (raw) Delta ESM gzip Delta
@module-federation/playground 28.91 MB -207 B (-0.0%) 45.7 kB no change
@module-federation/runtime 23.6 kB +3.2 kB (+15.6%) 1.1 kB +383 B (+52.9%)

Bundle targets

Package Web bundle (gzip) Delta Node bundle (gzip) Delta
@module-federation/cli 2.3 kB +2 B (+0.1%) 2.4 kB -30 B (-1.2%)
@module-federation/core 1.0 kB -4 B (-0.4%) 1.0 kB -31 B (-2.9%)
@module-federation/devtools 30.3 kB -7 B (-0.0%) 30.3 kB -28 B (-0.1%)
@module-federation/enhanced 2.7 kB +13 B (+0.5%) 2.8 kB -41 B (-1.4%)
@module-federation/metro-plugin-rnc-cli 416 B +2 B (+0.5%) 435 B -24 B (-5.2%)
@module-federation/node 9.1 kB +5 B (+0.1%) 9.2 kB -27 B (-0.3%)
@module-federation/runtime 693 B -4 B (-0.6%) 693 B -4 B (-0.6%)

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 693 B -4 B (-0.6%) 693 B -4 B (-0.6%) 0 B 0 B

Consumer scenarios

Scenario Web output (gzip) Delta Node output (gzip) Delta Gap (node-web) Delta
Enhanced remoteEntry 22.3 kB -11 B (-0.0%) 23.8 kB -15 B (-0.1%) +1.6 kB -4 B

Total dist (raw): 36.16 MB (+3.0 kB (+0.0%))
Total ESM gzip: 111.8 kB (+383 B (+0.3%))
Total web bundle (gzip): 253.1 kB (+7 B (+0.0%))
Total node bundle (gzip): 255.3 kB (-185 B (-0.1%))
Tracked ./bundler entry gzip: 563 B (no change)
Tracked ./bundler web bundle (gzip): 4.9 kB (-4 B (-0.1%))
Tracked ./bundler node bundle (gzip): 4.9 kB (-4 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

Copy link
Copy Markdown
Member Author

Context from #4566: with a remote that resolves @module-federation/runtime to the same copy as the host (externalized node_modules in an SSR build), executing the remote calls init() and re-pointed the top-level singleton. Observed in the harness: after one loadRemote, getInstance().name went from repro_host to repro_remote, and registerRemotes then wrote into the remote's empty options.remotes. For a host that refreshes remote definitions at runtime this silently disables the refresh loop.

For scale, the same investigation's measurements of the force path itself (balanced on every runtime version) are here:

force path

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant