From 1b86dee483b7331820f775dabf9456ede9d66dcb Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 7 Aug 2026 07:57:18 +0200 Subject: [PATCH 1/4] fix(module-federation-runtime): Throw a diagnostic for unsupported remote types The manifest is fetched at runtime and parseConfig only defaults `type`, it never validates it. A typo such as `{"type": "esm"}` therefore reached loadRemoteModule intact, matched neither the 'script' nor the 'module' branch, and left `loadRemoteEntryOptions` and `key` unassigned. loadRemoteEntry(undefined) then resolved without doing anything and lookupExposedModule(undefined, ...) failed with a TypeError naming neither the remote nor the bad type. Resolve the options into an internal union before use, so both variables are provably assigned, and throw an error naming the remote and the offending value instead. This also clears the 9 --strict errors in the file; all fixes are internal narrowing, and the generated .d.ts is unchanged for both entry points. Also drop a stale @ts-expect-error from test-setup.ts. The library had no spec, so the file was never compiled and the unused directive went unnoticed. --- .../loader/__fixtures__/esm-remote-entry.js | 17 ++ .../src/lib/loader/dynamic-federation.spec.ts | 215 ++++++++++++++++++ .../src/lib/loader/dynamic-federation.ts | 164 ++++++++----- libs/mf-runtime/src/test-setup.ts | 2 +- 4 files changed, 341 insertions(+), 57 deletions(-) create mode 100644 libs/mf-runtime/src/lib/loader/__fixtures__/esm-remote-entry.js create mode 100644 libs/mf-runtime/src/lib/loader/dynamic-federation.spec.ts diff --git a/libs/mf-runtime/src/lib/loader/__fixtures__/esm-remote-entry.js b/libs/mf-runtime/src/lib/loader/__fixtures__/esm-remote-entry.js new file mode 100644 index 00000000..594141c9 --- /dev/null +++ b/libs/mf-runtime/src/lib/loader/__fixtures__/esm-remote-entry.js @@ -0,0 +1,17 @@ +// Stands in for a webpack-built ESM remote entry. `loadRemoteModuleEntry` does a +// dynamic `import()` of the remoteEntry string, which the spec transform turns +// into a `require()` — so pointing remoteEntry at this file's absolute path +// exercises the real code path instead of mocking it away. + +const calls = { init: [], get: [] }; + +exports.calls = calls; + +exports.init = (shareScope) => { + calls.init.push(shareScope); +}; + +exports.get = (exposedModule) => { + calls.get.push(exposedModule); + return () => ({ loadedFrom: 'esm-remote-entry', exposedModule }); +}; diff --git a/libs/mf-runtime/src/lib/loader/dynamic-federation.spec.ts b/libs/mf-runtime/src/lib/loader/dynamic-federation.spec.ts new file mode 100644 index 00000000..dd37f060 --- /dev/null +++ b/libs/mf-runtime/src/lib/loader/dynamic-federation.spec.ts @@ -0,0 +1,215 @@ +import { join } from 'path'; + +type DynamicFederation = typeof import('./dynamic-federation'); + +// The module keeps `config`, `containerMap` and the share-scope flag in module +// scope, so every test needs a freshly required copy. +let df: DynamicFederation; + +const globalRef = globalThis as unknown as Record; + +// Absolute path so the `import()` inside loadRemoteModuleEntry resolves it. +const ESM_REMOTE_ENTRY = join(__dirname, '__fixtures__/esm-remote-entry.js'); + +type ScriptContainer = { + init: jest.Mock; + get: jest.Mock; +}; + +function createScriptContainer(): ScriptContainer { + return { + init: jest.fn(), + get: jest.fn((exposedModule: string) => () => ({ + loadedFrom: 'script-remote-entry', + exposedModule, + })), + }; +} + +// A real remote entry script registers `window[remoteName]` as a side effect of +// executing; jsdom never fetches the src, so we do it on appendChild and then +// fire onload by hand. +function stubScriptLoading(containers: Record): { + srcs: string[]; +} { + const srcs: string[] = []; + + jest + .spyOn(document.body, 'appendChild') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((node: any) => { + srcs.push(node.src); + Object.assign(globalRef, containers); + node.onload(); + return node; + }); + + return { srcs }; +} + +beforeEach(async () => { + jest.resetModules(); + jest.restoreAllMocks(); + + globalRef['__webpack_init_sharing__'] = jest + .fn() + .mockResolvedValue(undefined); + globalRef['__webpack_share_scopes__'] = { default: { fakeScope: true } }; + + df = await import('./dynamic-federation'); +}); + +describe('loadRemoteModule via a manifest', () => { + it('loads a remote declared as type "module"', async () => { + await df.initFederation( + { mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY } }, + true, + ); + + const module = await df.loadRemoteModule({ + type: 'manifest', + remoteName: 'mfe1', + exposedModule: './Component', + }); + + expect(module).toEqual({ + loadedFrom: 'esm-remote-entry', + exposedModule: './Component', + }); + }); + + it('loads a remote declared as type "script"', async () => { + const container = createScriptContainer(); + const { srcs } = stubScriptLoading({ mfe2: container }); + + await df.initFederation( + { + mfe2: { + type: 'script', + remoteEntry: 'http://localhost:4201/remoteEntry.js', + }, + }, + true, + ); + + const module = await df.loadRemoteModule({ + type: 'manifest', + remoteName: 'mfe2', + exposedModule: './Component', + }); + + expect(srcs).toEqual(['http://localhost:4201/remoteEntry.js']); + expect(container.init).toHaveBeenCalledWith({ fakeScope: true }); + expect(module).toEqual({ + loadedFrom: 'script-remote-entry', + exposedModule: './Component', + }); + }); + + it('defaults an entry with no type to "module"', async () => { + // parseConfig fills in `type: 'module'` for both shorthand strings and + // objects that omit it. + await df.initFederation({ mfe1: ESM_REMOTE_ENTRY }, true); + + expect(df.getManifest()).toEqual({ + mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY }, + }); + + await expect( + df.loadRemoteModule({ + type: 'manifest', + remoteName: 'mfe1', + exposedModule: './Component', + }), + ).resolves.toBeDefined(); + }); + + it('supports the legacy two-string overload', async () => { + await df.initFederation( + { mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY } }, + true, + ); + + const module = await df.loadRemoteModule('mfe1', './Component'); + + expect(module).toEqual({ + loadedFrom: 'esm-remote-entry', + exposedModule: './Component', + }); + }); + + it('throws when the remote is not in the manifest', async () => { + await df.initFederation( + { mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY } }, + true, + ); + + await expect( + df.loadRemoteModule('unknown-mfe', './Component'), + ).rejects.toThrow('Manifest does not contain unknown-mfe'); + }); + + it('throws a diagnostic naming the remote and the bad type', async () => { + // The manifest is fetched at runtime and never validated, so a typo like + // this reaches loadRemoteModule intact. + await df.initFederation( + { mfe1: { type: 'esm', remoteEntry: ESM_REMOTE_ENTRY } } as never, + true, + ); + + await expect(df.loadRemoteModule('mfe1', './Component')).rejects.toThrow( + 'Unsupported type "esm" for remote "mfe1" - expected "module" or "script"', + ); + }); +}); + +describe('loadRemoteModule without a manifest', () => { + it('treats options with no type as a script remote', async () => { + const container = createScriptContainer(); + const { srcs } = stubScriptLoading({ mfe2: container }); + + const module = await df.loadRemoteModule({ + remoteName: 'mfe2', + remoteEntry: 'http://localhost:4201/remoteEntry.js', + exposedModule: './Component', + }); + + expect(srcs).toEqual(['http://localhost:4201/remoteEntry.js']); + expect(module).toEqual({ + loadedFrom: 'script-remote-entry', + exposedModule: './Component', + }); + }); + + it('skips loading the remote entry when none is given', async () => { + const container = createScriptContainer(); + const { srcs } = stubScriptLoading({ mfe2: container }); + + // The entry was already pulled in by an earlier loadRemoteEntry call. + await df.loadRemoteEntry('http://localhost:4201/remoteEntry.js', 'mfe2'); + expect(srcs).toEqual(['http://localhost:4201/remoteEntry.js']); + + const module = await df.loadRemoteModule({ + remoteName: 'mfe2', + exposedModule: './Component', + }); + + expect(srcs).toHaveLength(1); + expect(module).toEqual({ + loadedFrom: 'script-remote-entry', + exposedModule: './Component', + }); + }); +}); + +describe('loadRemoteEntry', () => { + it('throws when the legacy overload is called without a remoteName', async () => { + await expect( + (df.loadRemoteEntry as unknown as (remoteEntry: string) => Promise)( + 'http://localhost:4201/remoteEntry.js', + ), + ).rejects.toThrow( + 'No remoteName passed for remote entry "http://localhost:4201/remoteEntry.js"', + ); + }); +}); diff --git a/libs/mf-runtime/src/lib/loader/dynamic-federation.ts b/libs/mf-runtime/src/lib/loader/dynamic-federation.ts index 6a6e3f33..06f2f113 100644 --- a/libs/mf-runtime/src/lib/loader/dynamic-federation.ts +++ b/libs/mf-runtime/src/lib/loader/dynamic-federation.ts @@ -63,8 +63,7 @@ async function initRemote(container: Container, key: string) { } export type LoadRemoteEntryOptions = - | LoadRemoteEntryScriptOptions - | LoadRemoteEntryEsmOptions; + LoadRemoteEntryScriptOptions | LoadRemoteEntryEsmOptions; export type LoadRemoteEntryScriptOptions = { type?: 'script'; @@ -92,6 +91,9 @@ export async function loadRemoteEntry( ): Promise { if (typeof remoteEntryOrOptions === 'string') { const remoteEntry = remoteEntryOrOptions; + if (!remoteName) { + throw new Error(`No remoteName passed for remote entry "${remoteEntry}"`); + } return await loadRemoteScriptEntry(remoteEntry, remoteName, nonce); } else if (remoteEntryOrOptions.type === 'script') { const options = remoteEntryOrOptions; @@ -139,7 +141,9 @@ async function loadRemoteScriptEntry( script.onerror = reject; script.onload = () => { - const container = window[remoteName] as Container; + const container = (window as unknown as Record)[ + remoteName + ] as Container; initRemote(container, remoteName); containerMap[remoteName] = container; resolve(); @@ -174,74 +178,122 @@ export type LoadRemoteModuleManifestOptions = { exposedModule: string; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function loadRemoteModule( +// `LoadRemoteModuleOptions` after the manifest lookup and the legacy defaulting +// have been applied, so `type` is always one of the two loadable variants. +type ResolvedRemoteModuleOptions = + | { + type: 'script'; + remoteEntry?: string; + remoteName: string; + exposedModule: string; + nonce?: string; + } + | { type: 'module'; remoteEntry: string; exposedModule: string }; + +function resolveManifestEntry( remoteName: string, exposedModule: string, -): Promise; -export async function loadRemoteModule( - options: LoadRemoteModuleOptions, -): Promise; -export async function loadRemoteModule( - optionsOrRemoteName: LoadRemoteModuleOptions | string, - exposedModule?: string, -): Promise { - let loadRemoteEntryOptions: LoadRemoteEntryOptions; - let key: string; - let remoteEntry: string; - let options: LoadRemoteModuleOptions; +): ResolvedRemoteModuleOptions { + const manifestEntry = config[remoteName]; - if (typeof optionsOrRemoteName === 'string') { - options = { - type: 'manifest', - remoteName: optionsOrRemoteName, - exposedModule: exposedModule, - }; - } else { - options = optionsOrRemoteName; + if (!manifestEntry) { + throw new Error('Manifest does not contain ' + remoteName); } - // To support legacy API (< ng 13) - if (!options.type) { - const hasManifest = Object.keys(config).length > 0; - options.type = hasManifest ? 'manifest' : 'script'; - } + // The manifest is fetched at runtime and parseConfig does not validate it, so + // `type` is only nominally 'module' | 'script'. + const type: string = manifestEntry.type; - if (options.type === 'manifest') { - const manifestEntry = config[options.remoteName]; - if (!manifestEntry) { - throw new Error('Manifest does not contain ' + options.remoteName); - } - options = { - type: manifestEntry.type, - exposedModule: options.exposedModule, + if (type === 'script') { + return { + type: 'script', remoteEntry: manifestEntry.remoteEntry, - remoteName: - manifestEntry.type === 'script' ? options.remoteName : undefined, + remoteName, + exposedModule, }; - remoteEntry = manifestEntry.remoteEntry; - } else { - remoteEntry = options.remoteEntry; } - if (options.type === 'script') { - loadRemoteEntryOptions = { - type: 'script', - remoteEntry: options.remoteEntry, - remoteName: options.remoteName, - nonce: options.nonce, - }; - key = options.remoteName; - } else if (options.type === 'module') { - loadRemoteEntryOptions = { + if (type === 'module') { + return { type: 'module', - remoteEntry: options.remoteEntry, + remoteEntry: manifestEntry.remoteEntry, + exposedModule, }; - key = options.remoteEntry; } + throw new Error( + `Unsupported type "${type}" for remote "${remoteName}" - expected "module" or "script"`, + ); +} + +function resolveOptions( + optionsOrRemoteName: LoadRemoteModuleOptions | string, + exposedModule?: string, +): ResolvedRemoteModuleOptions { + if (typeof optionsOrRemoteName === 'string') { + if (!exposedModule) { + throw new Error( + `No exposedModule passed for remote "${optionsOrRemoteName}"`, + ); + } + return resolveManifestEntry(optionsOrRemoteName, exposedModule); + } + + const options = optionsOrRemoteName; + + if (options.type === 'module') { + return options; + } + + if (options.type === 'manifest') { + return resolveManifestEntry(options.remoteName, options.exposedModule); + } + + // To support legacy API (< ng 13): a missing type means manifest whenever one + // is loaded, and script otherwise. + if (!options.type && Object.keys(config).length > 0) { + return resolveManifestEntry(options.remoteName, options.exposedModule); + } + + const type: string = options.type ?? 'script'; + if (type !== 'script') { + throw new Error( + `Unsupported type "${type}" for remote "${options.remoteName}" - expected "module" or "script"`, + ); + } + + return { ...options, type: 'script' }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function loadRemoteModule( + remoteName: string, + exposedModule: string, +): Promise; +export async function loadRemoteModule( + options: LoadRemoteModuleOptions, +): Promise; +export async function loadRemoteModule( + optionsOrRemoteName: LoadRemoteModuleOptions | string, + exposedModule?: string, +): Promise { + const options = resolveOptions(optionsOrRemoteName, exposedModule); + + const key = + options.type === 'script' ? options.remoteName : options.remoteEntry; + + const remoteEntry = options.remoteEntry; if (remoteEntry) { - await loadRemoteEntry(loadRemoteEntryOptions); + await loadRemoteEntry( + options.type === 'script' + ? { + type: 'script', + remoteEntry, + remoteName: options.remoteName, + nonce: options.nonce, + } + : { type: 'module', remoteEntry }, + ); } return await lookupExposedModule(key, options.exposedModule); diff --git a/libs/mf-runtime/src/test-setup.ts b/libs/mf-runtime/src/test-setup.ts index 0295e20b..526d0b77 100644 --- a/libs/mf-runtime/src/test-setup.ts +++ b/libs/mf-runtime/src/test-setup.ts @@ -1,4 +1,4 @@ -// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +// https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment globalThis.ngJest = { testEnvironmentOptions: { errorOnUnknownElements: true, From 72bb15ee56529bfc7c43d476f6bd355a06751980 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 7 Aug 2026 08:01:11 +0200 Subject: [PATCH 2/4] build(module-federation-runtime): Compile the library with strict: true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the unsupported-remote-type fix in place the library is strict-clean, so the setting no longer has to be relaxed. The only remaining violation was the `globalThis.ngJest` assignment in test-setup.ts, now written as an indexed access instead of a suppression so it holds under either setting. This lets apps/shell drop its own `strict: false`. That was never about the demo's code — the shell resolves @angular-architects/module-federation to libs/mf-runtime/src through the inherited tsconfig `paths`, and a strict program cannot consume loosely compiled sources. The demo is the thing people read and copy, so it should be checked the same way their app will be; it also restores Angular's null-related template checks, which derive from strictNullChecks. libs/mf and libs/mf-tools are still `strict: false` — nothing in the demo depends on them, so that is separate work. --- apps/README.md | 5 ----- apps/shell/tsconfig.app.json | 9 +-------- libs/mf-runtime/src/test-setup.ts | 2 +- libs/mf-runtime/tsconfig.json | 2 +- 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/apps/README.md b/apps/README.md index b78bd6d2..25ba7c62 100644 --- a/apps/README.md +++ b/apps/README.md @@ -76,11 +76,6 @@ These exist only because the demo lives inside the plugin's own repository: rebuilds `mf-runtime`, and ng-packagr recreates `dist/libs/mf-runtime`, which would yank the module out from under a dev-server already watching it. Using sources also means editing a library live-reloads the demo. -- `shell/tsconfig.app.json` sets `strict: false`. `libs/mf-runtime` is itself - compiled with `strict: false` and violates `strictNullChecks` and - `noImplicitAny`, and a strict program cannot consume those sources. Only the - app build is relaxed; the specs still type-check strictly. `mfe1` and `mfe2` - don't import the runtime, so they stay fully strict. Everything else is what `ng g @angular-architects/module-federation:init-webpack` generates for an Nx workspace: the `@nx/angular:webpack-browser` and diff --git a/apps/shell/tsconfig.app.json b/apps/shell/tsconfig.app.json index df12f72b..7d7a8070 100644 --- a/apps/shell/tsconfig.app.json +++ b/apps/shell/tsconfig.app.json @@ -2,14 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": [], - // In-repo demo only. The inherited `paths` resolve - // @angular-architects/module-federation to libs/mf-runtime/src, which is - // itself compiled with `strict: false` (see libs/mf-runtime/tsconfig.json) - // and violates both strictNullChecks and noImplicitAny. A strict program - // cannot consume those sources, so this build matches the library. Only - // tsconfig.app.json is relaxed — the specs still type-check strictly. - "strict": false + "types": [] }, "files": ["src/main.ts", "src/polyfills.ts"], "include": ["src/**/*.d.ts"], diff --git a/libs/mf-runtime/src/test-setup.ts b/libs/mf-runtime/src/test-setup.ts index 526d0b77..66fbd4f0 100644 --- a/libs/mf-runtime/src/test-setup.ts +++ b/libs/mf-runtime/src/test-setup.ts @@ -1,5 +1,5 @@ // https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment -globalThis.ngJest = { +(globalThis as unknown as Record)['ngJest'] = { testEnvironmentOptions: { errorOnUnknownElements: true, errorOnUnknownProperties: true, diff --git a/libs/mf-runtime/tsconfig.json b/libs/mf-runtime/tsconfig.json index 50487a0b..a808df06 100644 --- a/libs/mf-runtime/tsconfig.json +++ b/libs/mf-runtime/tsconfig.json @@ -4,7 +4,7 @@ "target": "es2022", "useDefineForClassFields": false, "forceConsistentCasingInFileNames": true, - "strict": false, + "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, From 0c375f7705da3260592bfa067c3d732c3264caf5 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 7 Aug 2026 08:06:01 +0200 Subject: [PATCH 3/4] fix(mf)!: Honour the skip list for sharedMappings `Array.prototype.filter` returns a new array. The result was discarded, so the call was a no-op and sharedMappings reached mappings.register() unfiltered. Assign it back. BREAKING CHANGE: a package that is both listed in `sharedMappings` and on the skip list is no longer mapped or shared. Affected names are the ones assembled into `skip`: tslib, zone.js, @angular-architects/module-federation, @angular-architects/module-federation-runtime, the three @softarc/* entries, @angular/router/upgrade, @angular/common/upgrade, and anything in the caller's own `skip` option. In practice these are not things people put in sharedMappings, which exists for monorepo libraries resolved through tsconfig `paths`, but a config relying on the old behaviour will silently stop sharing that package. The skip list is still only consulted for an explicit `sharedMappings` array. Omitting it maps every non-wildcard tsconfig path, skip-listed or not; the new spec pins that asymmetry rather than changing it. --- .../utils/__fixtures__/tsconfig.paths.json | 11 +++ libs/mf/src/utils/with-mf-plugin.spec.ts | 95 +++++++++++++++++++ libs/mf/src/utils/with-mf-plugin.ts | 4 +- 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 libs/mf/src/utils/__fixtures__/tsconfig.paths.json create mode 100644 libs/mf/src/utils/with-mf-plugin.spec.ts diff --git a/libs/mf/src/utils/__fixtures__/tsconfig.paths.json b/libs/mf/src/utils/__fixtures__/tsconfig.paths.json new file mode 100644 index 00000000..7d1dc5ae --- /dev/null +++ b/libs/mf/src/utils/__fixtures__/tsconfig.paths.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "paths": { + "@angular-architects/module-federation": ["mf-runtime/src/index.ts"], + "tslib": ["tslib/src/index.ts"], + "@angular/router/upgrade": ["router-upgrade/src/index.ts"], + "my-lib": ["my-lib/src/index.ts"], + "opt-out-lib": ["opt-out-lib/src/index.ts"] + } + } +} diff --git a/libs/mf/src/utils/with-mf-plugin.spec.ts b/libs/mf/src/utils/with-mf-plugin.spec.ts new file mode 100644 index 00000000..2b082abf --- /dev/null +++ b/libs/mf/src/utils/with-mf-plugin.spec.ts @@ -0,0 +1,95 @@ +import { join } from 'path'; + +// findRootTsConfigJson() walks up from cwd() to the real workspace tsconfig. +// Point it at a fixture instead so the mappings under test are fixed. +jest.mock('./share-utils', () => ({ + ...jest.requireActual('./share-utils'), + findRootTsConfigJson: jest.fn(), +})); + +import { findRootTsConfigJson } from './share-utils'; +import { withModuleFederationPlugin } from './with-mf-plugin'; + +const FIXTURE_TSCONFIG = join(__dirname, '__fixtures__/tsconfig.paths.json'); + +beforeEach(() => { + (findRootTsConfigJson as jest.Mock).mockReturnValue(FIXTURE_TSCONFIG); +}); + +// `shared: {}` keeps shareAll() — which reads the real root package.json — out +// of these tests. The sharedMappings descriptors are merged into it either way. +function build(config: Record) { + // withModuleFederationPlugin mutates the config it is handed, merging the + // mapping descriptors into `shared` before passing it to the webpack plugin. + const mfConfig: Record = { shared: {}, ...config }; + const result = withModuleFederationPlugin(mfConfig); + + return { + aliases: Object.keys(result.resolve.alias), + shared: Object.keys(mfConfig['shared']), + }; +} + +describe('withModuleFederationPlugin sharedMappings', () => { + it('maps a library that is not skip-listed', () => { + const { aliases, shared } = build({ sharedMappings: ['my-lib'] }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('drops entries on DEFAULT_SKIP_LIST', () => { + const { aliases, shared } = build({ + sharedMappings: [ + '@angular-architects/module-federation', + 'tslib', + 'my-lib', + ], + }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('drops entries on DEFAULT_SECONDARIES_SKIP_LIST', () => { + const { aliases, shared } = build({ + sharedMappings: ['@angular/router/upgrade', 'my-lib'], + }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('drops entries named in the caller-supplied skip option', () => { + const { aliases, shared } = build({ + sharedMappings: ['opt-out-lib', 'my-lib'], + skip: ['opt-out-lib'], + }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('maps nothing when every entry is skipped', () => { + // An empty array must not be mistaken for "no sharedMappings given", which + // is what turns on the map-every-tsconfig-path behaviour below. + const { aliases, shared } = build({ sharedMappings: ['tslib'] }); + + expect(aliases).toEqual([]); + expect(shared).toEqual([]); + }); + + it('maps every tsconfig path when sharedMappings is omitted', () => { + // Pre-existing asymmetry: the skip list is only consulted for an explicit + // sharedMappings array, so this path still maps skip-listed keys. + const { aliases } = build({}); + + expect(aliases).toEqual([ + '@angular-architects/module-federation', + 'tslib', + '@angular/router/upgrade', + 'my-lib', + 'opt-out-lib', + ]); + }); +}); diff --git a/libs/mf/src/utils/with-mf-plugin.ts b/libs/mf/src/utils/with-mf-plugin.ts index f4509713..444c8d17 100644 --- a/libs/mf/src/utils/with-mf-plugin.ts +++ b/libs/mf/src/utils/with-mf-plugin.ts @@ -10,7 +10,7 @@ import { ModifyEntryPlugin } from './modify-entry-plugin'; import ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); export function withModuleFederationPlugin(config: unknown) { - const sharedMappings = config['sharedMappings']; + let sharedMappings = config['sharedMappings']; delete config['sharedMappings']; const skip = [ @@ -22,7 +22,7 @@ export function withModuleFederationPlugin(config: unknown) { delete config['skip']; if (sharedMappings) { - sharedMappings.filter((m) => !skip.includes(m)); + sharedMappings = sharedMappings.filter((m) => !skip.includes(m)); } const mappings = new SharedMappings(); From a07eb70c067bb4703eddbf18ee256bd95ee7bba6 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 7 Aug 2026 08:12:03 +0200 Subject: [PATCH 4/4] fix(demo): Resolve @angular/core/testing in the app specs The three demo app specs have never run: all of them failed to compile with TS2307 on '@angular/core/testing'. That subpath exists only in the package's `exports` map -- there is no testing/ directory and no top-level fallback the way there is for the package root -- and tsconfig.base.json sets `moduleResolution: node`, which predates `exports` and cannot see it. Switch the spec configs to bundler resolution, which reads `exports`. It requires `module` to be es2015 or later, so that moves to esnext; jest-preset-angular emits CommonJS either way, which is why the suites now run. Only the specs are affected. The app builds go through Angular's builder, which brings its own resolution, and no other project imports an exports-only subpath -- libs/mf-tools only ever imports the @angular/core root, which still resolves through the package's top-level `typings`. --- apps/mfe1/tsconfig.spec.json | 7 ++++++- apps/mfe2/tsconfig.spec.json | 7 ++++++- apps/shell/tsconfig.spec.json | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/mfe1/tsconfig.spec.json b/apps/mfe1/tsconfig.spec.json index c5db0277..d9bf3849 100644 --- a/apps/mfe1/tsconfig.spec.json +++ b/apps/mfe1/tsconfig.spec.json @@ -2,7 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + // `@angular/core/testing` is reachable only through the package's `exports` + // map, which the inherited `moduleResolution: node` cannot read. Bundler + // resolution can, and it requires `module` to be es2015 or later — + // jest-preset-angular emits CommonJS regardless. + "module": "esnext", + "moduleResolution": "bundler", "types": ["jest", "node"] }, "files": ["src/test-setup.ts"], diff --git a/apps/mfe2/tsconfig.spec.json b/apps/mfe2/tsconfig.spec.json index c5db0277..d9bf3849 100644 --- a/apps/mfe2/tsconfig.spec.json +++ b/apps/mfe2/tsconfig.spec.json @@ -2,7 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + // `@angular/core/testing` is reachable only through the package's `exports` + // map, which the inherited `moduleResolution: node` cannot read. Bundler + // resolution can, and it requires `module` to be es2015 or later — + // jest-preset-angular emits CommonJS regardless. + "module": "esnext", + "moduleResolution": "bundler", "types": ["jest", "node"] }, "files": ["src/test-setup.ts"], diff --git a/apps/shell/tsconfig.spec.json b/apps/shell/tsconfig.spec.json index c5db0277..d9bf3849 100644 --- a/apps/shell/tsconfig.spec.json +++ b/apps/shell/tsconfig.spec.json @@ -2,7 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + // `@angular/core/testing` is reachable only through the package's `exports` + // map, which the inherited `moduleResolution: node` cannot read. Bundler + // resolution can, and it requires `module` to be es2015 or later — + // jest-preset-angular emits CommonJS regardless. + "module": "esnext", + "moduleResolution": "bundler", "types": ["jest", "node"] }, "files": ["src/test-setup.ts"],