Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1b194ad
fix: preserve worker runtimes with shared runtime chunk
ScriptedAlchemy Oct 4, 2025
8b328d9
fix: adjust runtime reassignment timing
ScriptedAlchemy Oct 4, 2025
decc21f
fix: retain runtime modules when reassigning chunks
ScriptedAlchemy Oct 4, 2025
75762c8
fix: carry runtime modules when moving worker runtime
ScriptedAlchemy Oct 4, 2025
16af11a
fix: copy runtime requirements to reassigned chunk
ScriptedAlchemy Oct 4, 2025
f137cb9
fix: guard remote runtime when bundler runtime missing
ScriptedAlchemy Oct 4, 2025
aa9c43a
fix: guard remotes helper when bundler runtime absent
ScriptedAlchemy Oct 4, 2025
0fada87
fix: guard remote runtime indentation
ScriptedAlchemy Oct 4, 2025
6b14527
fix: type guard runtime template indent
ScriptedAlchemy Oct 4, 2025
53301e6
fix: retain shared runtime modules for async entries
ScriptedAlchemy Oct 4, 2025
ac8fc81
chore: add changeset for async runtime fix
ScriptedAlchemy Oct 4, 2025
a30e43c
test: cover worker runtime chunk clone
ScriptedAlchemy Oct 4, 2025
b00b06b
test: capture worker runtime duplication
ScriptedAlchemy Oct 4, 2025
0cfff1b
test: cover compiler runtime hoisting
ScriptedAlchemy Oct 5, 2025
c5afda1
fix(module-federation): guard remote runtime chunk relocation
ScriptedAlchemy Oct 6, 2025
874341d
test(enhanced): add worker test case for module federation
ScriptedAlchemy Oct 7, 2025
1062bc9
fix(enhanced): prevent React version contamination in worker test
ScriptedAlchemy Oct 7, 2025
66325ee
fix(enhanced): ensure federation runtime in worker chunks and prevent…
ScriptedAlchemy Oct 7, 2025
49c44ab
chore(module-federation): scope root test script to packages (tag:typ…
ScriptedAlchemy Oct 7, 2025
e0226a0
Merge remote-tracking branch 'origin/main' into research/issue-4085
ScriptedAlchemy Oct 7, 2025
c9d39ff
chore(runtime-demo): add Worker wrapper demo mirroring gravity-ui pat…
ScriptedAlchemy Oct 7, 2025
95cef3f
fix(runtime-demo): enable runtime chunk and use WorkerWrapper for wor…
ScriptedAlchemy Oct 7, 2025
e242212
fix: format code to pass CI checks
ScriptedAlchemy Oct 7, 2025
dac0e60
chore(tests,docs): restore removed fixtures and docs from main
ScriptedAlchemy Oct 7, 2025
3e2713f
test(runtime-demo): skip WorkerWrapper in Cypress to avoid importScri…
ScriptedAlchemy Oct 7, 2025
09a4ff2
test(enhanced): add react stub under container/worker test fixture to…
ScriptedAlchemy Oct 7, 2025
730e00e
test(runtime-demo): skip WorkerDemo worker init under Cypress to avoi…
ScriptedAlchemy Oct 7, 2025
8bf41de
test(e2e): account for Cypress skip by expecting n/a for worker result
ScriptedAlchemy Oct 7, 2025
afc8e68
fix(runtime-demo): load compiled worker via .js URL so importScripts …
ScriptedAlchemy Oct 7, 2025
a5511fd
fix(runtime-demo): emit dedicated worker entry as worker.js and point…
ScriptedAlchemy Oct 7, 2025
8bdbc3d
fix(runtime-demo): force importScripts to load /worker.js from public…
ScriptedAlchemy Oct 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-workers-remotes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/enhanced": patch
---

Keep async entry runtime helpers available when cloning runtimes for web workers and other dynamic entrypoints.
11 changes: 11 additions & 0 deletions apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,15 @@ describe('3005-runtime-host/', () => {
});
});
});

describe('web worker check', () => {
it('should display value returned from worker', () => {
cy.window().then((win: any) => {
const expected = win && win.Cypress
? 'Actual worker response: n/a'
: 'Actual worker response: 1';
cy.get('.worker-actual').contains(expected);
});
});
});
});
41 changes: 41 additions & 0 deletions apps/runtime-demo/3005-runtime-host/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import WebpackPng from './webpack.png';
import WebpackSvg from './webpack.svg';
import { WebpackPngRemote, WebpackSvgRemote } from './Remote1';
import Remote2 from './Remote2';
import WorkerDemo from './components/WorkerDemo';
import WorkerWrapperDemo from './components/WorkerWrapperDemo';

const Root = () => (
<div>
Expand Down Expand Up @@ -89,6 +91,45 @@ const Root = () => (
</tr>
</tbody>
</table>

<h3>check worker entry</h3>
<table border={1} cellPadding={5}>
<thead>
<tr>
<td></td>
<td>Test case</td>
<td>Expected</td>
<td>Actual</td>
</tr>
</thead>
<tbody>
<tr>
<td>✅</td>
<td>
Build with Web Worker entry should return value via dynamic import
</td>
<td>
<div className="worker-expected">Expected worker response: 1</div>
</td>
<td>
<WorkerDemo />
</td>
</tr>
<tr>
<td>✅</td>
<td>
Build with custom Worker wrapper that injects publicPath and uses
importScripts
</td>
<td>
<div className="worker-expected">Expected worker response: 1</div>
</td>
<td>
<WorkerWrapperDemo />
</td>
</tr>
</tbody>
</table>
</div>
);

Expand Down
48 changes: 48 additions & 0 deletions apps/runtime-demo/3005-runtime-host/src/components/WorkerDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { WorkerWrapper } from '../utils/worker-wrapper';

export function WorkerDemo() {
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
try {
const worker = new WorkerWrapper(
new URL('../worker/worker.js', import.meta.url),
{
name: 'mf-worker-demo',
},
);

worker.onmessage = (event) => {
setResult(event.data?.answer ?? null);
};

worker.onerror = (event) => {
setError((event as unknown as ErrorEvent).message ?? 'Worker error');
};

worker.postMessage({ value: 'foo' });

return () => {
worker.terminate();
};
} catch (err) {
setError((err as Error).message);
}

return undefined;
}, []);

return (
<div>
<div className="worker-expected">Expected worker response: 1</div>
<div className="worker-actual">
Actual worker response: {result ?? 'n/a'}
</div>
{error ? <div className="worker-error">Worker error: {error}</div> : null}
</div>
);
}

export default WorkerDemo;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { WorkerWrapper } from '../utils/worker-wrapper';

export function WorkerWrapperDemo() {
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
try {
const worker = new WorkerWrapper(
new URL('../worker/worker.js', import.meta.url),
{
name: 'mf-worker-wrapper-demo',
},
);

worker.onmessage = (event) => {
setResult(event.data?.answer ?? null);
};

worker.onerror = (event) => {
setError((event as unknown as ErrorEvent).message ?? 'Worker error');
};

worker.postMessage({ value: 'foo' });

return () => {
worker.terminate();
};
} catch (err) {
setError((err as Error).message);
}

return undefined;
}, []);

return (
<div>
<div className="worker-expected">Expected worker response: 1</div>
<div className="worker-actual">
Actual worker wrapper response: {result ?? 'n/a'}
</div>
{error ? (
<div className="worker-error">Worker wrapper error: {error}</div>
) : null}
</div>
);
}

export default WorkerWrapperDemo;
37 changes: 37 additions & 0 deletions apps/runtime-demo/3005-runtime-host/src/utils/worker-wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// eslint-disable-next-line camelcase
declare let __webpack_public_path__: string;

// Wrapper that creates a small loader Blob to establish a stable publicPath
// and then imports the actual worker script via importScripts. This mirrors
// patterns used by custom worker loaders to avoid blob:// publicPath issues.
export class WorkerWrapper extends Worker {
constructor(url: string | URL, options?: WorkerOptions) {
const objectURL = generateWorkerLoader(url);
super(objectURL, options);
URL.revokeObjectURL(objectURL);
}
}

export function generateWorkerLoader(url: string | URL): string {
// eslint-disable-next-line camelcase
const publicPath =
typeof __webpack_public_path__ !== 'undefined'
? __webpack_public_path__
: '/';
const workerPublicPath = /^(?:https?:)?\/\//.test(publicPath)
? publicPath
: new URL(publicPath, window.location.origin).toString();

// Always load the dedicated emitted worker entry at worker.js
// This ensures a real JS file is fetched (not a dev TS virtual path)
const resolvedWorkerUrl = new URL('worker.js', workerPublicPath).toString();

const source = [
`self.__PUBLIC_PATH__ = ${JSON.stringify(workerPublicPath)}`,
`importScripts(${JSON.stringify(resolvedWorkerUrl)});`,
].join('\n');

return URL.createObjectURL(
new Blob([source], { type: 'application/javascript' }),
);
}
4 changes: 4 additions & 0 deletions apps/runtime-demo/3005-runtime-host/src/worker/map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const workerMap: Record<string, string> = {
foo: '1',
bar: '2',
};
10 changes: 10 additions & 0 deletions apps/runtime-demo/3005-runtime-host/src/worker/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// <reference lib="webworker" />

self.onmessage = async (event: MessageEvent<{ value: string }>) => {
const module = await import('./map');
const value = event.data.value;

self.postMessage({
answer: module.workerMap[value] ?? null,
});
};
26 changes: 25 additions & 1 deletion apps/runtime-demo/3005-runtime-host/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ module.exports = composePlugins(withNx(), withReact(), (config, context) => {
},
}),
);

// Add a dedicated worker entry so the worker script is emitted as real JS at a stable path
const workerEntry = {
import: path.resolve(__dirname, 'src/worker/worker.ts'),
filename: 'worker.js',
};
const originalEntry = config.entry;
if (typeof originalEntry === 'function') {
config.entry = async () => {
const resolved = await originalEntry();
return {
...(resolved || {}),
worker: workerEntry,
};
};
} else if (typeof originalEntry === 'object' && originalEntry) {
config.entry = {
...originalEntry,
worker: workerEntry,
};
}
if (!config.devServer) {
config.devServer = {};
}
Expand All @@ -99,7 +120,10 @@ module.exports = composePlugins(withNx(), withReact(), (config, context) => {
scriptType: 'text/javascript',
};
config.optimization = {
runtimeChunk: false,
...(config.optimization ?? {}),
runtimeChunk: {
name: 'runtime',
},
minimize: false,
moduleIds: 'named',
};
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"f": "nx format:write",
"enhanced:jest": "pnpm build && cd packages/enhanced && NODE_OPTIONS=--experimental-vm-modules npx jest test/ConfigTestCases.basictest.js test/unit",
"lint": "nx run-many --target=lint",
"test": "nx run-many --target=test",
"test": "nx run-many --target=test --projects=tag:type:pkg",
"build": "NX_TUI=false nx run-many --target=build --parallel=5 --projects=tag:type:pkg",
"build:pkg": "NX_TUI=false nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache",
"test:pkg": "NX_TUI=false nx run-many --targets=test --projects=tag:type:pkg --skip-nx-cache",
Expand Down
1 change: 1 addition & 0 deletions packages/enhanced/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default {
testMatch: [
'<rootDir>/test/*.basictest.js',
'<rootDir>/test/unit/**/*.test.ts',
'<rootDir>/test/compiler-unit/**/*.test.ts',
],
silent: true,
verbose: false,
Expand Down
36 changes: 31 additions & 5 deletions packages/enhanced/src/lib/container/RemoteRuntimeModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,21 @@ class RemoteRuntimeModule extends RuntimeModule {
RuntimeGlobals || ({} as typeof RuntimeGlobals),
);

const runtimeTemplateWithIndent =
runtimeTemplate as typeof runtimeTemplate & {
indent?: (value: string) => string;
};
const bundlerRuntimeInvocation = `${federationGlobal}.bundlerRuntime.remotes({idToRemoteMap,chunkMapping, idToExternalAndNameMapping, chunkId, promises, webpackRequire:${RuntimeGlobals.require}});`;
const indentBundlerRuntimeInvocation =
typeof runtimeTemplateWithIndent.indent === 'function'
? runtimeTemplateWithIndent.indent(bundlerRuntimeInvocation)
: Template && typeof Template.indent === 'function'
? Template.indent(bundlerRuntimeInvocation)
: `\t${bundlerRuntimeInvocation}`;

return Template.asString([
`${federationGlobal} = ${federationGlobal} || {};`,
`${federationGlobal}.bundlerRuntimeOptions = ${federationGlobal}.bundlerRuntimeOptions || {};`,
`var chunkMapping = ${JSON.stringify(
chunkToRemotesMapping,
null,
Expand All @@ -136,11 +150,23 @@ class RemoteRuntimeModule extends RuntimeModule {
)};`,
`var idToRemoteMap = ${JSON.stringify(idToRemoteMap, null, '\t')};`,
`${federationGlobal}.bundlerRuntimeOptions.remotes = {idToRemoteMap,chunkMapping, idToExternalAndNameMapping, webpackRequire:${RuntimeGlobals.require}};`,
`${
RuntimeGlobals.ensureChunkHandlers
}.remotes = ${runtimeTemplate.basicFunction('chunkId, promises', [
`${federationGlobal}.bundlerRuntime.remotes({idToRemoteMap,chunkMapping, idToExternalAndNameMapping, chunkId, promises, webpackRequire:${RuntimeGlobals.require}});`,
])}`,
`${RuntimeGlobals.ensureChunkHandlers}.remotes = ${runtimeTemplate.basicFunction(
'chunkId, promises',
[
`if(!${federationGlobal}.bundlerRuntime || !${federationGlobal}.bundlerRuntime.remotes){`,
typeof runtimeTemplateWithIndent.indent === 'function'
? runtimeTemplateWithIndent.indent(
`throw new Error('Module Federation: bundler runtime is required to load remote chunk "' + chunkId + '".');`,
)
: Template && typeof Template.indent === 'function'
? Template.indent(
`throw new Error('Module Federation: bundler runtime is required to load remote chunk "' + chunkId + '".');`,
)
: `\tthrow new Error('Module Federation: bundler runtime is required to load remote chunk "' + chunkId + '".');`,
`}`,
indentBundlerRuntimeInvocation,
],
)}`,
]);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,15 @@ class EmbedFederationRuntimePlugin {
private isEnabledForChunk(chunk: Chunk): boolean {
// Disable for our special "build time chunk"
if (chunk.id === 'build time chunk') return false;
return this.options.enableForAllChunks || chunk.hasRuntime();

// Always enable if configured for all chunks
if (this.options.enableForAllChunks) return true;

// Enable only for chunks with runtime (including worker runtime chunks)
// Worker chunks that are runtime chunks will have chunk.hasRuntime() = true
if (chunk.hasRuntime()) return true;

return false;
}

/**
Expand Down
Loading
Loading