Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 53 additions & 7 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ permissions:
pull-requests: read

jobs:
run-playwright-tests:
name: Playwright Tests
# Dev mode tests - uses vite dev server
run-playwright-tests-dev:
name: Playwright Tests (Dev Mode)
runs-on: ubuntu-latest
container: node:20
steps:
Expand All @@ -31,19 +32,64 @@ jobs:
- name: Install Chromium Browser
run: pnpm playwright install --with-deps chromium

- name: Build Projects
- name: Build Plugin
run: pnpm build

- name: Start Application multi-example
- name: Start Application (Dev Mode)
run: nohup pnpm run multi-example & pnpm exec wait-on http://localhost:5173;

- name: Run Playwright Tests
run: pnpm playwright test
- name: Run Playwright Tests (Dev)
run: pnpm playwright test --project=multi-example

- name: Upload Artifacts on Failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results
name: test-results-dev
path: reports/e2e/output
retention-days: 3

# Build/Preview mode tests - validates production builds work correctly
# This catches issues like Vite 7/Rolldown CJS wrapper problems
run-playwright-tests-preview:
name: Playwright Tests (Build/Preview Mode)
runs-on: ubuntu-latest
container: node:20
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Enable Corepack and Setup PNPM
run: |
corepack enable
corepack prepare pnpm@9.1.3 --activate

- name: Install Dependencies
run: pnpm install --frozen-lockfile

- name: Install Chromium Browser
run: pnpm playwright install --with-deps chromium

- name: Build Plugin
run: pnpm build

- name: Build Example Apps
run: pnpm --filter "multi-example-*" run build

- name: Start Application (Preview Mode)
run: |
cd examples/vite-webpack-rspack/host
nohup pnpm preview --port 4173 &
cd ../../..
pnpm exec wait-on http://localhost:4173

- name: Run Playwright Tests (Preview)
run: pnpm playwright test --project=multi-example-preview

- name: Upload Artifacts on Failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results-preview
path: reports/e2e/output
retention-days: 3
9 changes: 9 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ export default defineConfig({
browserName: 'chromium',
},
},
// Test production build via preview mode - validates Vite 7/Rolldown compatibility
{
name: 'multi-example-preview',
testDir: 'e2e/vite-webpack-rspack',
use: {
baseURL: 'http://localhost:4173',
browserName: 'chromium',
},
},
],
outputDir: 'reports/e2e/output',
reporter: [
Expand Down
43 changes: 42 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './utils/normalizeModuleFederationOptions';
import normalizeOptimizeDepsPlugin from './utils/normalizeOptimizeDeps';
import VirtualModule from './utils/VirtualModule';
// wrapManualChunks not used - direct function assignment is simpler and works better
import {
getHostAutoInitImportId,
getHostAutoInitPath,
Expand All @@ -29,17 +30,21 @@ function federation(mfUserOptions: ModuleFederationOptions): Plugin[] {
const options = normalizeModuleFederationOptions(mfUserOptions);
const { name, remotes, shared, filename, hostInitInjectLocation } = options;
if (!name) throw new Error('name is required');
let _command: string = 'serve';

return [
{
name: 'vite:module-federation-config',
enforce: 'pre',
config(config, { command }) {
_command = command;
},
configResolved(config) {
// Set root path
VirtualModule.setRoot(config.root);
// Ensure virtual package directory exists
VirtualModule.ensureVirtualPackageExists();
initVirtualModules();
initVirtualModules(_command);
},
},
aliasToArrayPlugin,
Expand Down Expand Up @@ -96,6 +101,42 @@ function federation(mfUserOptions: ModuleFederationOptions): Plugin[] {
config.optimizeDeps?.include?.push(virtualDir);
config.optimizeDeps?.needsInterop?.push(virtualDir);
config.optimizeDeps?.needsInterop?.push(getLocalSharedImportMapPath());

// FIX: Isolate preload helper to prevent deadlock with loadShare TLA
// This prevents a circular deadlock where:
// 1. hostInit imports preload helper from a shared chunk
// 2. That chunk has loadShare TLA waiting for initPromise
// 3. initPromise only resolves after remoteEntry.init() is called
// 4. But hostInit can't call init() until the shared chunk loads → DEADLOCK
if (_command === 'build') {
config.build = config.build || {};
config.build.rollupOptions = config.build.rollupOptions || {};
config.build.rollupOptions.output = config.build.rollupOptions.output || {};

const output = Array.isArray(config.build.rollupOptions.output)
? config.build.rollupOptions.output[0]
: config.build.rollupOptions.output;

const existingManualChunks = output.manualChunks;
output.manualChunks = (id: string, meta: any) => {
// Isolate preload helper to prevent deadlock
if (
id.includes('vite/preload-helper') ||
id.includes('vite/modulepreload-polyfill') ||
id.includes('commonjsHelpers')
) {
return 'preload-helper';
}
// Call existing manualChunks if it exists
if (typeof existingManualChunks === 'function') {
return existingManualChunks(id, meta);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering if this part could cause breaking changes for anyone relying on manual checks since a set of vite plugins (line 118-120) won't be checked anymore.

}
if (existingManualChunks && (existingManualChunks as any)[id]) {
return (existingManualChunks as any)[id];
}
return undefined;
};
}
},
},
...pluginManifest(),
Expand Down
6 changes: 4 additions & 2 deletions src/plugins/pluginProxySharedModule_preBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ export function proxySharedModule(options: {
config(config: UserConfig, { command }) {
(config.resolve as any).alias.push(
...Object.keys(shared).map((key) => {
// When key ends with '/', only match subpaths (e.g., 'react/' matches 'react/jsx-runtime' but NOT 'react')
// When key does NOT end with '/', match both exact and subpaths (e.g., 'react' matches 'react' AND 'react/jsx-runtime')
const pattern = key.endsWith('/')
? `(^${key.replace(/\/$/, '')}(\/.+)?$)`
: `(^${key}$)`;
? `(^${key.replace(/\/$/, '')}/.+$)`
: `(^${key}(/.+)?$)`;
return {
// Intercept all shared requests and proxy them to loadShare
find: new RegExp(pattern),
Expand Down
4 changes: 2 additions & 2 deletions src/virtualModules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export { generateExposes, VIRTUAL_EXPOSES } from './virtualExposes';

export { virtualRuntimeInitStatus } from './virtualRuntimeInitStatus';

export function initVirtualModules() {
export function initVirtualModules(command: string = 'serve') {
writeLocalSharedImportMap();
writeHostAutoInit();
writeRuntimeInitStatus();
writeRuntimeInitStatus(command);
}
24 changes: 18 additions & 6 deletions src/virtualModules/virtualRemotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,22 @@ export function getUsedRemotesMap() {
return usedRemotesMap;
}
export function generateRemotes(id: string, command: string) {
return `
const {initPromise} = require("${virtualRuntimeInitStatus.getImportId()}")
const res = initPromise.then(runtime => runtime.loadRemote(${JSON.stringify(id)}))
const exportModule = ${command !== 'build' ? '/*mf top-level-await placeholder replacement mf*/' : 'await '}initPromise.then(_ => res)
module.exports = exportModule
`;
if (command === 'build') {
// Build mode: Use ESM syntax to fix Vite 7/Rolldown compatibility
// Rolldown wraps CJS (require + module.exports) in a function, breaking top-level await
return `
import { initPromise } from "${virtualRuntimeInitStatus.getImportId()}"
const res = initPromise.then(runtime => runtime.loadRemote(${JSON.stringify(id)}))
const exportModule = await initPromise.then(_ => res)
export default exportModule
`;
} else {
// Dev mode: Use original CJS syntax for compatibility with existing plugins
return `
const {initPromise} = require("${virtualRuntimeInitStatus.getImportId()}")
const res = initPromise.then(runtime => runtime.loadRemote(${JSON.stringify(id)}))
const exportModule = /*mf top-level-await placeholder replacement mf*/initPromise.then(_ => res)
module.exports = exportModule
`;
}
}
54 changes: 39 additions & 15 deletions src/virtualModules/virtualRuntimeInitStatus.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
import VirtualModule from '../utils/VirtualModule';
export const virtualRuntimeInitStatus = new VirtualModule('runtimeInit');
export function writeRuntimeInitStatus() {
export function writeRuntimeInitStatus(command: string) {
// Use globalThis singleton to ensure only one initPromise exists
const globalKey = `__mf_init__${virtualRuntimeInitStatus.getImportId()}__`;
virtualRuntimeInitStatus.writeSync(`
const globalKey = ${JSON.stringify(globalKey)}
if (!globalThis[globalKey]) {
let initResolve, initReject
const initPromise = new Promise((re, rj) => {
initResolve = re
initReject = rj
})
globalThis[globalKey] = {
initPromise,
initResolve,
initReject

if (command === 'build') {
// Build mode: Use ESM syntax to fix Vite 7/Rolldown compatibility
virtualRuntimeInitStatus.writeSync(`
const globalKey = ${JSON.stringify(globalKey)}
if (!globalThis[globalKey]) {
let initResolve, initReject
const initPromise = new Promise((re, rj) => {
initResolve = re
initReject = rj
})
globalThis[globalKey] = {
initPromise,
initResolve,
initReject
}
}
}
module.exports = globalThis[globalKey]
export const initPromise = globalThis[globalKey].initPromise
export const initResolve = globalThis[globalKey].initResolve
export const initReject = globalThis[globalKey].initReject
`);
} else {
// Dev mode: Use CJS syntax for compatibility
virtualRuntimeInitStatus.writeSync(`
const globalKey = ${JSON.stringify(globalKey)}
if (!globalThis[globalKey]) {
let initResolve, initReject
const initPromise = new Promise((re, rj) => {
initResolve = re
initReject = rj
})
globalThis[globalKey] = {
initPromise,
initResolve,
initReject
}
}
module.exports = globalThis[globalKey]
`);
}
}
48 changes: 33 additions & 15 deletions src/virtualModules/virtualShared_preBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,37 @@ export function getLoadShareModulePath(pkg: string): string {
return filepath;
}
export function writeLoadShareModule(pkg: string, shareItem: ShareItem, command: string) {
loadShareCacheMap[pkg].writeSync(`
;() => import(${JSON.stringify(getPreBuildLibImportId(pkg))}).catch(() => {});
// dev uses dynamic import to separate chunks
${command !== 'build' ? `;() => import(${JSON.stringify(pkg)}).catch(() => {});` : ''}
const {initPromise} = require("${virtualRuntimeInitStatus.getImportId()}")
const res = initPromise.then(runtime => runtime.loadShare(${JSON.stringify(pkg)}, {
customShareInfo: {shareConfig:{
singleton: ${shareItem.shareConfig.singleton},
strictVersion: ${shareItem.shareConfig.strictVersion},
requiredVersion: ${JSON.stringify(shareItem.shareConfig.requiredVersion)}
}}
}))
const exportModule = ${command !== 'build' ? '/*mf top-level-await placeholder replacement mf*/' : 'await '}res.then(factory => factory())
module.exports = exportModule
`);
if (command === 'build') {
// Build mode: Use ESM syntax to fix Vite 7/Rolldown compatibility
// Rolldown wraps CJS (require + module.exports) in a function, breaking top-level await
loadShareCacheMap[pkg].writeSync(`
import { initPromise } from "${virtualRuntimeInitStatus.getImportId()}"
;() => import(${JSON.stringify(getPreBuildLibImportId(pkg))}).catch(() => {});
const res = initPromise.then(runtime => runtime.loadShare(${JSON.stringify(pkg)}, {
customShareInfo: {shareConfig:{
singleton: ${shareItem.shareConfig.singleton},
strictVersion: ${shareItem.shareConfig.strictVersion},
requiredVersion: ${JSON.stringify(shareItem.shareConfig.requiredVersion)}
}}
}))
const exportModule = await res.then(factory => factory())
export default exportModule
`);
} else {
// Dev mode: Use original CJS syntax for compatibility with existing plugins
loadShareCacheMap[pkg].writeSync(`
;() => import(${JSON.stringify(getPreBuildLibImportId(pkg))}).catch(() => {});
;() => import(${JSON.stringify(pkg)}).catch(() => {});
const {initPromise} = require("${virtualRuntimeInitStatus.getImportId()}")
const res = initPromise.then(runtime => runtime.loadShare(${JSON.stringify(pkg)}, {
customShareInfo: {shareConfig:{
singleton: ${shareItem.shareConfig.singleton},
strictVersion: ${shareItem.shareConfig.strictVersion},
requiredVersion: ${JSON.stringify(shareItem.shareConfig.requiredVersion)}
}}
}))
const exportModule = /*mf top-level-await placeholder replacement mf*/res.then(factory => factory())
module.exports = exportModule
`);
}
}
Loading