Skip to content

fix(native-federation-node): resolve import map scopes like the v4 Node loader - #1129

Open
Zakurama wants to merge 2 commits into
angular-architects:21.x.xfrom
Zakurama:fix/nf-node-import-map-scope-resolution
Open

Zakurama wants to merge 2 commits into
angular-architects:21.x.xfrom
Zakurama:fix/nf-node-import-map-scope-resolution

Conversation

@Zakurama

Copy link
Copy Markdown

fix(native-federation-node): resolve import map scopes like the v4 Node loader


Problem

On SSR, federation-resolver.mjs links every remote except the first one of the manifest against the host's version of each shared package, ignoring the remote's own scope. The browser (es-module-shims) resolves the same import map correctly. So when a remote shares a package in a different version than the host, the server renders it with code it wasn't built against and the browser hydrates it with another version.

Cause: 21.x.x differs from v4

v4's Node loader (orchestrator node-loader.ts) checks every scope and only then falls back to top-level imports. The 21.x.x loader has an extra else that returns the top-level entry at the first scope that doesn't match:

  for (… scopePrefix in importMap.scopes) {
    if (scopePrefix === currentBaseURL || (… currentBaseURL.startsWith(scopePrefix))) {
      const scopeImportsMatch = resolveImportsMatch(normalizedSpecifier, importMap.scopes[scopePrefix]);
      if (scopeImportsMatch) return scopeImportsMatch;
-   } else {
-     const topLevelImportsMatch = resolveImportsMatch(normalizedSpecifier, importMap.imports);
-     if (topLevelImportsMatch) return topLevelImportsMatch;   // ← 21.x.x only, not in v4
    }
  }
  return resolveImportsMatch(normalizedSpecifier, importMap.imports);

This PR removes that branch, so the 21.x.x loop becomes identical to v4's, and to the import maps spec and es-module-shims.

Reproduction (no install)

From the repo root, run this on 21.x.x, then on this branch:

node --no-warnings --input-type=module <<'EOF'
import { resolveSpecifier } from './libs/native-federation-node/src/lib/utils/import-map-loader.js';
const importMap = {
  imports: { dep: 'http://host/dep-1.0.0.js' },
  scopes: {
    'http://remote-a/': { dep: 'http://host/dep-1.0.0.js' },
    'http://remote-b/': { dep: 'http://remote-b/dep-2.0.0.js' },
  },
};
console.log(resolveSpecifier(importMap, 'dep', 'http://remote-b/chunk.js'));
EOF
  • 21.x.x prints http://host/dep-1.0.0.js ❌: remote-a doesn't match, so the else returns the host entry.
  • This PR prints http://remote-b/dep-2.0.0.js ✅, like the browser and v4.

Commits

  1. fix: import-map-loader.js, 8 lines removed.
  2. chore: regenerates the base64 copies that actually run (loader-as-data-url.js, the string inlined in fstart.mjs, fstart-as-data-url.ts), one line each. In fstart.mjs only the embedded string is replaced, since re-bundling with esbuild would pull in unrelated changes.
Verify commit 2 (no install): each copy decodes back to its source
node <<'EOF'
const fs = require('fs');
const read = (file) => fs.readFileSync(file, 'utf8');
const decode = (file) => Buffer.from(read(file).match(/'([A-Za-z0-9+/=]{100,})'/)[1], 'base64').toString();
const utils = 'libs/native-federation-node/src/lib/utils/';

const checks = {
  'loader-as-data-url.js == import-map-loader.js': decode(utils + 'loader-as-data-url.js') === read(utils + 'import-map-loader.js'),
  'fstart.mjs resolver   == import-map-loader.js': decode(utils + 'fstart.mjs') === read(utils + 'import-map-loader.js'),
  'fstart-as-data-url.ts == fstart.mjs':           decode('libs/native-federation/src/tools/fstart-as-data-url.ts') === read(utils + 'fstart.mjs'),
};
console.table(checks);
process.exit(Object.values(checks).every(Boolean) ? 0 : 1);
EOF

All three should print true.

Risk

  • Unchanged: modules under the first scope, modules outside any scope, and specifiers missing from the matching scope (all still fall back to top-level imports).
  • Changed: only remotes that aren't first now get the entry their own scope already declares.
  • No API or import-map format change.

nx format:check and lint build for native-federation-node and native-federation pass.

Why it matters to us

In our app, a remote on our design system 3.7.0 was rendered on the server with the host's 3.4.1. After hydration, Angular removed the server-rendered styles, and buttons lost their styling. This happens silently, since hydration checks are dev-only. A 21.x patch release of @softarc/native-federation-node would unblock us.

…el imports

resolveSpecifier() looked up the top-level imports in the `else` branch
of the scope loop. The first scope that did not contain the importing
module therefore returned the host's entry, and the later scopes, among
them the importing module's own scope, were never checked.

With SSR, every remote except the first one of the manifest is linked
against the host's version of each shared package, while the browser
(es-module-shims) correctly uses the remote's scope. Server-rendered
markup and hydrating code then come from different versions of the same
library.

Remove the `else` branch: only scopes containing the importing module
are checked, and the top-level imports, already returned after the
loop, remain the fallback.
The resolver written to federation-resolver.mjs at runtime is not
import-map-loader.js itself but base64 copies of it:

- native-federation-node/src/lib/utils/loader-as-data-url.js
  (node libs/native-federation-node/build/create-data-url.js)
- the same string inlined in native-federation-node/src/lib/utils/fstart.mjs
- native-federation/src/tools/fstart-as-data-url.ts, base64 of fstart.mjs
  (node libs/native-federation/build/create-data-url.js)

Only the base64 string of fstart.mjs is replaced: re-bundling it with
esbuild would also pull in unrelated runtime changes made since it was
last bundled.

Each copy decodes back to its source, see the verification script in
the pull request.
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