diff --git a/packages/enhanced/.eslintrc.json b/packages/enhanced/.eslintrc.json index 9d308ccab48..10f3c0fec65 100644 --- a/packages/enhanced/.eslintrc.json +++ b/packages/enhanced/.eslintrc.json @@ -3,7 +3,7 @@ "ignorePatterns": ["!**/*", "**/*.d.ts"], "overrides": [ { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "files": ["*.ts", "*.tsx", "*.js", "*.jsx", ".cjs"], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/ban-ts-comment": "warn", diff --git a/packages/enhanced/jest.config.cjs b/packages/enhanced/jest.config.cjs new file mode 100644 index 00000000000..65d81b3a893 --- /dev/null +++ b/packages/enhanced/jest.config.cjs @@ -0,0 +1,47 @@ +/* eslint-disable */ +var { readFileSync } = require('fs'); +var path = require('path'); +var os = require('os'); +var rimraf = require('rimraf'); + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +var swcJestConfig = JSON.parse( + readFileSync(path.join(__dirname, '.swcrc'), 'utf-8'), +); + +rimraf.sync(path.join(__dirname, 'test/js')); + +// Disable .swcrc look-up by SWC core because we're passing in swcJestConfig +// ourselves. If we do not disable this, SWC Core will read .swcrc and won't +// transform our test files due to "exclude". +if (swcJestConfig && typeof swcJestConfig.exclude !== 'undefined') { + delete swcJestConfig.exclude; +} + +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +/** @type {import('jest').Config} */ +var config = { + displayName: 'enhanced', + preset: '../../jest.preset.js', + cacheDirectory: path.join(os.tmpdir(), 'enhanced'), + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/enhanced', + rootDir: __dirname, + testMatch: [ + '/test/*.basictest.js', + '/test/unit/**/*.test.ts', + ], + silent: true, + verbose: false, + testEnvironment: path.resolve(__dirname, './test/patch-node-env.js'), + setupFilesAfterEnv: ['/test/setupTestFramework.js'], +}; + +module.exports = config; diff --git a/packages/enhanced/project.json b/packages/enhanced/project.json index 57375dba310..8205d06ffaf 100644 --- a/packages/enhanced/project.json +++ b/packages/enhanced/project.json @@ -49,7 +49,7 @@ "parallel": false, "commands": [ { - "command": "node --expose-gc --max-old-space-size=4096 --experimental-vm-modules --trace-deprecation ./node_modules/jest-cli/bin/jest --logHeapUsage --config packages/enhanced/jest.config.ts --silent", + "command": "node --expose-gc --max-old-space-size=4096 --experimental-vm-modules --trace-deprecation ./node_modules/jest-cli/bin/jest --logHeapUsage --config packages/enhanced/jest.config.cjs --silent", "forwardAllArgs": false } ] diff --git a/packages/enhanced/src/lib/container/ContainerEntryModule.ts b/packages/enhanced/src/lib/container/ContainerEntryModule.ts index fc248c4f255..c1e1b498fa6 100644 --- a/packages/enhanced/src/lib/container/ContainerEntryModule.ts +++ b/packages/enhanced/src/lib/container/ContainerEntryModule.ts @@ -57,6 +57,10 @@ export type ExposeOptions = { * custom chunk name for the exposed module */ name: string; + /** + * optional webpack layer to assign to the exposed module + */ + layer?: string; }; class ContainerEntryModule extends Module { @@ -187,6 +191,10 @@ class ContainerEntryModule extends Module { let idx = 0; for (const request of options.import) { const dep = new ContainerExposedDependency(name, request); + // apply per-expose module layer if provided + if (options.layer) { + dep.layer = options.layer; + } dep.loc = { name, index: idx++, diff --git a/packages/enhanced/src/lib/container/ContainerExposedDependency.ts b/packages/enhanced/src/lib/container/ContainerExposedDependency.ts index 263f88abe67..d9cf81b85c9 100644 --- a/packages/enhanced/src/lib/container/ContainerExposedDependency.ts +++ b/packages/enhanced/src/lib/container/ContainerExposedDependency.ts @@ -19,6 +19,8 @@ import type { class ContainerExposedDependency extends dependencies.ModuleDependency { exposedName: string; override request: string; + // optional layer to assign to the created normal module + layer?: string; /** * @param {string} exposedName public name @@ -50,6 +52,7 @@ class ContainerExposedDependency extends dependencies.ModuleDependency { */ override serialize(context: ObjectSerializerContext): void { context.write(this.exposedName); + context.write(this.layer); super.serialize(context); } @@ -58,6 +61,7 @@ class ContainerExposedDependency extends dependencies.ModuleDependency { */ override deserialize(context: ObjectDeserializerContext): void { this.exposedName = context.read(); + this.layer = context.read(); super.deserialize(context); } } diff --git a/packages/enhanced/src/lib/container/ContainerPlugin.ts b/packages/enhanced/src/lib/container/ContainerPlugin.ts index 623e5aec584..304acf329a9 100644 --- a/packages/enhanced/src/lib/container/ContainerPlugin.ts +++ b/packages/enhanced/src/lib/container/ContainerPlugin.ts @@ -42,6 +42,17 @@ const validate = createSchemaValidation(checkOptions, () => schema, { const PLUGIN_NAME = 'ContainerPlugin'; +type ExposesConfigInput = { + import: string | string[]; + name?: string; + layer?: string; +}; +type ExposesConfig = { + import: string[]; + name: string | undefined; + layer?: string; +}; + class ContainerPlugin { _options: containerPlugin.ContainerPluginOptions; name: string; @@ -59,16 +70,19 @@ class ContainerPlugin { }, runtime: options.runtime, filename: options.filename || undefined, - //@ts-ignore - exposes: parseOptions( - options.exposes, + //@ts-ignore normalized tuple form used internally + exposes: parseOptions( + // supports array or object shapes + options.exposes as containerPlugin.ContainerPluginOptions['exposes'], (item) => ({ import: Array.isArray(item) ? item : [item], name: undefined, + layer: undefined, }), (item) => ({ import: Array.isArray(item.import) ? item.import : [item.import], name: item.name || undefined, + layer: item.layer || undefined, }), ), runtimePlugins: options.runtimePlugins, @@ -322,6 +336,30 @@ class ContainerPlugin { normalModuleFactory, ); } + + // Propagate per-expose `layer` from ContainerExposedDependency to the created NormalModule + normalModuleFactory.hooks.createModule.tapAsync( + PLUGIN_NAME, + ( + createData: { layer?: string } & Record, + resolveData: { dependencies?: import('webpack').Dependency[] }, + callback: (err?: Error | null) => void, + ) => { + try { + const deps = resolveData?.dependencies || []; + const first = deps[0]; + if (first && first instanceof ContainerExposedDependency) { + const layer = (first as ContainerExposedDependency).layer; + if (layer) { + createData.layer = layer; + } + } + callback(); + } catch (e) { + callback(e as Error); + } + }, + ); }, ); diff --git a/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts b/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts index 5863a678270..b64322fd7e8 100644 --- a/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts +++ b/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts @@ -757,43 +757,56 @@ class ConsumeSharedPlugin { if (!pkgName) { pkgName = await getPackageNameForResource(resource); } - if (!pkgName) return; - // Candidate configs: include - // - exact package name keys (legacy behavior) - // - deep-path shares whose keys start with `${pkgName}/` (alias-aware) const candidates: ConsumeOptions[] = []; const seen = new Set(); - const k1 = createLookupKeyForSharing(pkgName, issuerLayer); - const k2 = createLookupKeyForSharing(pkgName, undefined); - const c1 = unresolvedConsumes.get(k1); - const c2 = unresolvedConsumes.get(k2); - if (c1 && !seen.has(c1)) { - candidates.push(c1); - seen.add(c1); - } - if (c2 && !seen.has(c2)) { - candidates.push(c2); - seen.add(c2); + + if (originalRequest) { + const directCfg = + unresolvedConsumes.get( + createLookupKeyForSharing(originalRequest, issuerLayer), + ) || + unresolvedConsumes.get( + createLookupKeyForSharing(originalRequest, undefined), + ); + if (directCfg && !seen.has(directCfg)) { + candidates.push(directCfg); + seen.add(directCfg); + } } - // Also scan for deep-path keys beginning with `${pkgName}/` (both layered and unlayered) - const prefixLayered = createLookupKeyForSharing( - pkgName + '/', - issuerLayer, - ); - const prefixUnlayered = createLookupKeyForSharing( - pkgName + '/', - undefined, - ); - for (const [key, cfg] of unresolvedConsumes) { - if ( - (key.startsWith(prefixLayered) || - key.startsWith(prefixUnlayered)) && - !seen.has(cfg) - ) { - candidates.push(cfg); - seen.add(cfg); + if (pkgName) { + const k1 = createLookupKeyForSharing(pkgName, issuerLayer); + const k2 = createLookupKeyForSharing(pkgName, undefined); + const c1 = unresolvedConsumes.get(k1); + const c2 = unresolvedConsumes.get(k2); + if (c1 && !seen.has(c1)) { + candidates.push(c1); + seen.add(c1); + } + if (c2 && !seen.has(c2)) { + candidates.push(c2); + seen.add(c2); + } + + // Also scan for deep-path keys beginning with `${pkgName}/` (both layered and unlayered) + const prefixLayered = createLookupKeyForSharing( + pkgName + '/', + issuerLayer, + ); + const prefixUnlayered = createLookupKeyForSharing( + pkgName + '/', + undefined, + ); + for (const [key, cfg] of unresolvedConsumes) { + if ( + (key.startsWith(prefixLayered) || + key.startsWith(prefixUnlayered)) && + !seen.has(cfg) + ) { + candidates.push(cfg); + seen.add(cfg); + } } } if (candidates.length === 0) return; diff --git a/packages/enhanced/test/compiler-unit/sharing/ConsumeSharedPlugin.alias-consume-generic.test.ts b/packages/enhanced/test/compiler-unit/sharing/ConsumeSharedPlugin.alias-consume-generic.test.ts new file mode 100644 index 00000000000..6f49488601e --- /dev/null +++ b/packages/enhanced/test/compiler-unit/sharing/ConsumeSharedPlugin.alias-consume-generic.test.ts @@ -0,0 +1,373 @@ +/* + * @jest-environment node + */ +import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; +import type { Configuration } from 'webpack'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import ConsumeSharedPlugin from '../../../src/lib/sharing/ConsumeSharedPlugin'; + +const webpack = require(normalizeWebpackPath('webpack')); + +const compile = (compiler: any): Promise => + new Promise((resolve, reject) => { + compiler.run((err: Error | null | undefined, stats: any) => { + if (err) reject(err); + else resolve(stats); + }); + }); + +interface CreateCompilerOpts { + context: string; + entry: string; + resolve: Record; + plugins: any[]; +} + +const createCompiler = ({ + context, + entry, + resolve, + plugins, +}: CreateCompilerOpts) => { + const config: Configuration = { + mode: 'development', + context, + entry, + output: { + path: path.join(context, 'dist'), + filename: 'bundle.js', + }, + resolve, + plugins, + infrastructureLogging: { level: 'error' }, + stats: 'errors-warnings', + }; + return webpack(config); +}; + +describe('ConsumeSharedPlugin - alias consumption generic path-equality', () => { + let testDir: string; + let srcDir: string; + let nmDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mf-consume-alias-')); + srcDir = path.join(testDir, 'src'); + nmDir = path.join(testDir, 'node_modules'); + + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(path.join(nmDir, 'next/dist/compiled/react'), { + recursive: true, + }); + fs.mkdirSync(path.join(nmDir, 'next/dist/compiled/react-dom/client'), { + recursive: true, + }); + fs.mkdirSync(path.join(nmDir, 'next/dist/compiled/react'), { + recursive: true, + }); + + // Stub compiled React and ReactDOM client entries without package.json nearby + fs.writeFileSync( + path.join(nmDir, 'next/dist/compiled/react/index.js'), + 'module.exports = { marker: "compiled-react" };', + ); + fs.writeFileSync( + path.join(nmDir, 'next/dist/compiled/react-dom/client.js'), + 'module.exports = { marker: "compiled-react-dom-client" };', + ); + fs.writeFileSync( + path.join(nmDir, 'next/dist/compiled/react/jsx-runtime.js'), + 'module.exports = { jsx: function(){ return "compiled-jsx"; } };', + ); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('maps bare "react" and "react-dom/client" to consumes when aliased to compiled paths', async () => { + // Import bare specifiers (will be aliased to compiled locations) + fs.writeFileSync( + path.join(srcDir, 'index.js'), + [ + "import React from 'react';", + "import ReactDomClient from 'react-dom/client';", + 'console.log(React.marker, ReactDomClient.marker);', + ].join('\n'), + ); + + const compiler = createCompiler({ + context: testDir, + entry: path.join(srcDir, 'index.js'), + resolve: { + alias: { + react: path.join(nmDir, 'next/dist/compiled/react'), + 'react-dom/client': path.join( + nmDir, + 'next/dist/compiled/react-dom/client.js', + ), + }, + }, + plugins: [ + new ConsumeSharedPlugin({ + experiments: { aliasConsumption: true }, + consumes: { + react: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + }, + 'react-dom/client': { + import: 'react-dom/client', + shareKey: 'react-dom/client', + shareScope: 'default', + requiredVersion: false, + }, + }, + }), + ], + }); + + const stats = await compile(compiler); + expect(stats.hasErrors()).toBe(false); + const json = stats.toJson({ modules: true }); + + const consumeModules = (json.modules || []).filter( + (m: any) => m.moduleType === 'consume-shared-module', + ); + + // Verify that both consumes were created + expect( + consumeModules.some((m: any) => String(m.name).includes('react')), + ).toBe(true); + expect( + consumeModules.some((m: any) => + String(m.name).includes('react-dom/client'), + ), + ).toBe(true); + }); + + it('supports prefix consumes (react/) via generic resolver mapping for jsx-runtime', async () => { + fs.writeFileSync( + path.join(srcDir, 'prefix.js'), + ["import jsx from 'react/jsx-runtime';", 'console.log(!!jsx.jsx);'].join( + '\n', + ), + ); + + const compiler = createCompiler({ + context: testDir, + entry: path.join(srcDir, 'prefix.js'), + resolve: { + alias: { + 'react/jsx-runtime': path.join( + nmDir, + 'next/dist/compiled/react/jsx-runtime.js', + ), + }, + }, + plugins: [ + new ConsumeSharedPlugin({ + experiments: { aliasConsumption: true }, + consumes: { + 'react/': { + import: 'react/', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + include: { request: /jsx-runtime$/ }, + allowNodeModulesSuffixMatch: true, + }, + }, + }), + ], + }); + + const stats = await compile(compiler); + expect(stats.hasErrors()).toBe(false); + const json = stats.toJson({ modules: true }); + const consumeModules = (json.modules || []).filter( + (m: any) => m.moduleType === 'consume-shared-module', + ); + expect( + consumeModules.some((m: any) => + String(m.name).includes('react/jsx-runtime'), + ), + ).toBe(true); + }); + + it('respects issuer layer when configured and still resolves via alias (Windows-style alias path)', async () => { + const layerDir = path.join(srcDir, 'layerA'); + fs.mkdirSync(layerDir, { recursive: true }); + fs.writeFileSync( + path.join(layerDir, 'index.js'), + [ + "import React from 'react';", + 'console.log(React && React.marker);', + ].join('\n'), + ); + + // Simulate a Windows-style absolute path in alias target + const winAlias = (p: string) => p.split(path.sep).join('\\\\'); + + const compiler = createCompiler({ + context: testDir, + entry: path.join(layerDir, 'index.js'), + resolve: { + alias: { + react: winAlias(path.join(nmDir, 'next/dist/compiled/react')), + }, + }, + plugins: [ + new ConsumeSharedPlugin({ + experiments: { aliasConsumption: true }, + consumes: { + react: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + layer: 'pages-dir-browser', + issuerLayer: 'pages-dir-browser', + allowNodeModulesSuffixMatch: true, + }, + }, + }), + ], + }); + + // Attach a rule to assign issuer layer to layerA + (compiler.options.module = compiler.options.module || {}).rules = [ + { + test: /layerA[\\/].*\.js$/, + layer: 'pages-dir-browser', + }, + ]; + + const stats = await compile(compiler); + expect(stats.hasErrors()).toBe(false); + const json = stats.toJson({ modules: true }); + const consumeModules = (json.modules || []).filter( + (m: any) => m.moduleType === 'consume-shared-module', + ); + expect(consumeModules.length).toBeGreaterThan(0); + expect( + consumeModules.some((m: any) => + String(m.name).includes('(pages-dir-browser)'), + ), + ).toBe(true); + }); + + it('does not map across layers when issuerLayer mismatches', async () => { + // Entry under layer "layer-A" + const layerDir = path.join(srcDir, 'layerA2'); + fs.mkdirSync(layerDir, { recursive: true }); + fs.writeFileSync( + path.join(layerDir, 'index.js'), + ["import React from 'react';", 'console.log(!!React);'].join('\n'), + ); + + const compiler = createCompiler({ + context: testDir, + entry: path.join(layerDir, 'index.js'), + resolve: { + alias: { + react: path.join(nmDir, 'next/dist/compiled/react'), + }, + }, + plugins: [ + new ConsumeSharedPlugin({ + experiments: { aliasConsumption: true }, + consumes: { + // Configure consume only for a different layer + react$B: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-B', + layer: 'layer-B', + allowNodeModulesSuffixMatch: true, + }, + }, + }), + ], + }); + + // Assign issuer layer to source module + (compiler.options.module = compiler.options.module || {}).rules = [ + { test: /layerA2[\\/].*\.js$/, layer: 'layer-A' }, + ]; + + const stats = await compile(compiler); + expect(stats.hasErrors()).toBe(false); + const json = stats.toJson({ modules: true }); + const consumeModules = (json.modules || []).filter( + (m: any) => m.moduleType === 'consume-shared-module', + ); + // No consume mapping should be created due to issuerLayer mismatch + expect(consumeModules.length).toBe(0); + }); + + it('prefers matching issuerLayer when multiple consume configs exist', async () => { + // Entry under layer "layer-A" + const layerDir = path.join(srcDir, 'layerA3'); + fs.mkdirSync(layerDir, { recursive: true }); + fs.writeFileSync( + path.join(layerDir, 'index.js'), + ["import React from 'react';", 'console.log(!!React);'].join('\n'), + ); + + const compiler = createCompiler({ + context: testDir, + entry: path.join(layerDir, 'index.js'), + resolve: { + alias: { + react: path.join(nmDir, 'next/dist/compiled/react'), + }, + }, + plugins: [ + new ConsumeSharedPlugin({ + experiments: { aliasConsumption: true }, + consumes: { + react$B: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-B', + layer: 'layer-B', + allowNodeModulesSuffixMatch: true, + }, + react$A: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-A', + layer: 'layer-A', + allowNodeModulesSuffixMatch: true, + }, + }, + }), + ], + }); + + (compiler.options.module = compiler.options.module || {}).rules = [ + { test: /layerA3[\\/].*\.js$/, layer: 'layer-A' }, + ]; + + const stats = await compile(compiler); + expect(stats.hasErrors()).toBe(false); + const json = stats.toJson({ modules: true }); + const consumeModules = (json.modules || []).filter( + (m: any) => m.moduleType === 'consume-shared-module', + ); + expect(consumeModules.length).toBe(1); + // Ensure the consume references the matching issuer layer in its readable name + expect(String(consumeModules[0].name)).toContain('(layer-A)'); + }); +}); diff --git a/packages/enhanced/test/compiler-unit/sharing/ProvideSharedPlugin.test.ts b/packages/enhanced/test/compiler-unit/sharing/ProvideSharedPlugin.test.ts index be8a3e7e2db..ecbe70f925b 100644 --- a/packages/enhanced/test/compiler-unit/sharing/ProvideSharedPlugin.test.ts +++ b/packages/enhanced/test/compiler-unit/sharing/ProvideSharedPlugin.test.ts @@ -1336,7 +1336,7 @@ describe('ProvideSharedPlugin', () => { expect(sharedModules.length).toBe(1); }); - it('should warn when using singleton with request exclusion', async () => { + it('should NOT warn when using singleton with request exclusion', async () => { // Setup scoped package structure const scopeDir = path.join(nodeModulesDir, '@scope/prefix'); fs.mkdirSync(path.join(scopeDir, 'excluded-path'), { recursive: true }); @@ -1418,7 +1418,7 @@ describe('ProvideSharedPlugin', () => { expect(stats.hasErrors()).toBe(false); - // Check for warnings about singleton with exclude.request + // Check for warnings about singleton with exclude.request (should not warn for request filters) const warnings = stats.compilation.warnings; const hasSingletonWarning = warnings.some( (warning) => @@ -1427,7 +1427,7 @@ describe('ProvideSharedPlugin', () => { warning.message.includes('@scope/prefix/'), ); - expect(hasSingletonWarning).toBe(true); + expect(hasSingletonWarning).toBe(false); }); it('should warn when using singleton with request inclusion', async () => { @@ -1499,7 +1499,7 @@ describe('ProvideSharedPlugin', () => { expect(stats.hasErrors()).toBe(false); - // Check for warnings about singleton with include.request + // Check for warnings about singleton with include.request (should not warn for request filters) const warnings = stats.compilation.warnings; const hasSingletonWarning = warnings.some( (warning) => @@ -1508,7 +1508,7 @@ describe('ProvideSharedPlugin', () => { warning.message.includes('@scope/prefix/'), ); - expect(hasSingletonWarning).toBe(true); + expect(hasSingletonWarning).toBe(false); }); }); }); diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/index.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/index.js new file mode 100644 index 00000000000..ef7e0771974 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/index.js @@ -0,0 +1,21 @@ +it('prefers ModuleFederation shared provider over local alias fallback', async () => { + // Aliased bare imports should surface the provided shared implementations + const [React, ReactTarget] = await Promise.all([ + import('react'), + import('next/dist/compiled/react'), + ]); + const [ReactDomClient, ReactDomClientTarget] = await Promise.all([ + import('react-dom/client'), + import('next/dist/compiled/react-dom/client'), + ]); + + // Provided shares override the local compiled alias targets regardless of import path + expect(React.marker).toBe('provided-react'); + expect(ReactTarget.marker).toBe('provided-react'); + expect(ReactDomClient.marker).toBe('provided-react-dom-client'); + expect(ReactDomClientTarget.marker).toBe('provided-react-dom-client'); +}); + +module.exports = { + testName: 'consume-with-aliases-generic-provider', +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react-dom/client.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react-dom/client.js new file mode 100644 index 00000000000..992270c57b5 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react-dom/client.js @@ -0,0 +1,4 @@ +const stub = { id: 'provided-react-dom-client', marker: 'provided-react-dom-client' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react.js new file mode 100644 index 00000000000..1064d27baf9 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react.js @@ -0,0 +1,4 @@ +const stub = { id: 'provided-react', marker: 'provided-react', jsx: 'provided-jsx' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react/index.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react/index.js new file mode 100644 index 00000000000..1064d27baf9 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/dist/compiled/react/index.js @@ -0,0 +1,4 @@ +const stub = { id: 'provided-react', marker: 'provided-react', jsx: 'provided-jsx' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/package.json new file mode 100644 index 00000000000..2315724cb7c --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/next/package.json @@ -0,0 +1,5 @@ +{ + "name": "next", + "version": "13.4.0", + "description": "Next.js compiled React shim used for alias consumption tests" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react-dom/client.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react-dom/client.js new file mode 100644 index 00000000000..992270c57b5 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react-dom/client.js @@ -0,0 +1,4 @@ +const stub = { id: 'provided-react-dom-client', marker: 'provided-react-dom-client' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react-dom/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react-dom/package.json new file mode 100644 index 00000000000..bd5a6748a16 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react-dom/package.json @@ -0,0 +1,5 @@ +{ + "name": "provided-react-dom", + "version": "18.0.0", + "description": "Federation provided ReactDOM stub consumed via explicit import" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react/index.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react/index.js new file mode 100644 index 00000000000..1064d27baf9 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react/index.js @@ -0,0 +1,4 @@ +const stub = { id: 'provided-react', marker: 'provided-react', jsx: 'provided-jsx' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react/package.json new file mode 100644 index 00000000000..863716a0ea8 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/provided/react/package.json @@ -0,0 +1,5 @@ +{ + "name": "provided-react", + "version": "18.0.0", + "description": "Federation provided React stub consumed via explicit import" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react-dom/client.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react-dom/client.js new file mode 100644 index 00000000000..5d66d731cb6 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react-dom/client.js @@ -0,0 +1,10 @@ +// Regular ReactDOM client stub that should be bypassed when aliasing works +module.exports = { + name: 'regular-react-dom-client', + version: '18.0.0', + source: 'node_modules/react-dom/client', + marker: 'regular-react-dom-client', + createRoot: function () { + return 'WRONG-regular-react-dom-client'; + }, +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react-dom/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react-dom/package.json new file mode 100644 index 00000000000..b82dfa352d4 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react-dom/package.json @@ -0,0 +1,5 @@ +{ + "name": "react-dom", + "version": "18.0.0", + "description": "Regular ReactDOM stub used to verify alias consumption fallback" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react/index.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react/index.js new file mode 100644 index 00000000000..02b8c4a1ad5 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react/index.js @@ -0,0 +1,11 @@ +// Regular React stub used to ensure alias consumption picks the compiled build +module.exports = { + name: 'regular-react', + version: '18.0.0', + source: 'node_modules/react', + instanceId: 'regular-react-instance', + marker: 'regular-react', + createElement: function () { + return 'WRONG-regular-react-element'; + }, +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react/package.json new file mode 100644 index 00000000000..9dc54038ab2 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/node_modules/react/package.json @@ -0,0 +1,5 @@ +{ + "name": "react", + "version": "18.0.0", + "description": "Regular React stub used to verify alias consumption fallback" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/package.json new file mode 100644 index 00000000000..bfdaae98e5c --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/package.json @@ -0,0 +1,4 @@ +{ + "name": "consume-with-aliases-generic-provider", + "version": "1.0.0" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/webpack.config.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/webpack.config.js new file mode 100644 index 00000000000..52e536009a6 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic-provider/webpack.config.js @@ -0,0 +1,44 @@ +const { ModuleFederationPlugin } = require('../../../../dist/src'); +const path = require('path'); + +module.exports = { + mode: 'development', + devtool: false, + resolve: { + alias: { + react: path.resolve(__dirname, 'node_modules/next/dist/compiled/react'), + 'next/dist/compiled/react': path.resolve( + __dirname, + 'node_modules/next/dist/compiled/react', + ), + 'react-dom/client': path.resolve( + __dirname, + 'node_modules/next/dist/compiled/react-dom/client.js', + ), + 'next/dist/compiled/react-dom/client': path.resolve( + __dirname, + 'node_modules/next/dist/compiled/react-dom/client.js', + ), + }, + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'consume-with-aliases-generic-provider', + experiments: { asyncStartup: false, aliasConsumption: true }, + shared: { + 'next/dist/compiled/react': { + singleton: true, + eager: true, + requiredVersion: false, + allowNodeModulesSuffixMatch: true, + }, + 'next/dist/compiled/react-dom/client': { + singleton: true, + eager: true, + requiredVersion: false, + allowNodeModulesSuffixMatch: true, + }, + }, + }), + ], +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/index.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/index.js new file mode 100644 index 00000000000..9afff358c26 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/index.js @@ -0,0 +1,10 @@ +it('consumes aliased React and ReactDOM client via generic resolver mapping', async () => { + const React = await import('react'); + const ReactDomClient = await import('react-dom/client'); + expect(React.marker).toBe('compiled-react'); + expect(ReactDomClient.marker).toBe('compiled-react-dom-client'); +}); + +module.exports = { + testName: 'consume-with-aliases-generic', +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react-dom/client.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react-dom/client.js new file mode 100644 index 00000000000..fed4a53fedb --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react-dom/client.js @@ -0,0 +1,4 @@ +const stub = { id: 'compiled-react-dom-client', marker: 'compiled-react-dom-client' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react.js new file mode 100644 index 00000000000..5fdc8ffe819 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react.js @@ -0,0 +1,4 @@ +const stub = { id: 'compiled-react', marker: 'compiled-react', jsx: 'compiled-jsx' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react/index.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react/index.js new file mode 100644 index 00000000000..5fdc8ffe819 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/dist/compiled/react/index.js @@ -0,0 +1,4 @@ +const stub = { id: 'compiled-react', marker: 'compiled-react', jsx: 'compiled-jsx' }; +stub.__esModule = true; +stub.default = stub; +module.exports = stub; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/package.json new file mode 100644 index 00000000000..2315724cb7c --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/next/package.json @@ -0,0 +1,5 @@ +{ + "name": "next", + "version": "13.4.0", + "description": "Next.js compiled React shim used for alias consumption tests" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react-dom/client.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react-dom/client.js new file mode 100644 index 00000000000..5d66d731cb6 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react-dom/client.js @@ -0,0 +1,10 @@ +// Regular ReactDOM client stub that should be bypassed when aliasing works +module.exports = { + name: 'regular-react-dom-client', + version: '18.0.0', + source: 'node_modules/react-dom/client', + marker: 'regular-react-dom-client', + createRoot: function () { + return 'WRONG-regular-react-dom-client'; + }, +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react-dom/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react-dom/package.json new file mode 100644 index 00000000000..b82dfa352d4 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react-dom/package.json @@ -0,0 +1,5 @@ +{ + "name": "react-dom", + "version": "18.0.0", + "description": "Regular ReactDOM stub used to verify alias consumption fallback" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react/index.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react/index.js new file mode 100644 index 00000000000..02b8c4a1ad5 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react/index.js @@ -0,0 +1,11 @@ +// Regular React stub used to ensure alias consumption picks the compiled build +module.exports = { + name: 'regular-react', + version: '18.0.0', + source: 'node_modules/react', + instanceId: 'regular-react-instance', + marker: 'regular-react', + createElement: function () { + return 'WRONG-regular-react-element'; + }, +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react/package.json new file mode 100644 index 00000000000..9dc54038ab2 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/node_modules/react/package.json @@ -0,0 +1,5 @@ +{ + "name": "react", + "version": "18.0.0", + "description": "Regular React stub used to verify alias consumption fallback" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/package.json b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/package.json new file mode 100644 index 00000000000..09c245db82f --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/package.json @@ -0,0 +1,4 @@ +{ + "name": "consume-with-aliases-generic", + "version": "1.0.0" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/webpack.config.js b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/webpack.config.js new file mode 100644 index 00000000000..f9b115f00a4 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-with-aliases-generic/webpack.config.js @@ -0,0 +1,43 @@ +const { ModuleFederationPlugin } = require('../../../../dist/src'); +const path = require('path'); + +module.exports = { + mode: 'development', + devtool: false, + resolve: { + alias: { + react: path.resolve(__dirname, 'node_modules/next/dist/compiled/react'), + 'next/dist/compiled/react': path.resolve( + __dirname, + 'node_modules/next/dist/compiled/react', + ), + 'react-dom/client': path.resolve( + __dirname, + 'node_modules/next/dist/compiled/react-dom/client.js', + ), + 'next/dist/compiled/react-dom/client': path.resolve( + __dirname, + 'node_modules/next/dist/compiled/react-dom/client.js', + ), + }, + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'consume-with-aliases-generic', + experiments: { asyncStartup: true, aliasConsumption: true }, + shared: { + // Provide the aliased targets; consumer will import bare specifiers + 'next/dist/compiled/react': { + singleton: true, + eager: true, + allowNodeModulesSuffixMatch: true, + }, + 'next/dist/compiled/react-dom/client': { + singleton: true, + eager: true, + allowNodeModulesSuffixMatch: true, + }, + }, + }), + ], +}; diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.alias-fallback-import-false.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.alias-fallback-import-false.test.ts new file mode 100644 index 00000000000..1b4a81370e9 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.alias-fallback-import-false.test.ts @@ -0,0 +1,139 @@ +/* + * @jest-environment node + */ + +import { + ConsumeSharedPlugin, + mockGetDescriptionFile, +} from './shared-test-utils'; + +jest.setTimeout(15000); + +describe('ConsumeSharedPlugin - alias fallback preserves import=false semantics', () => { + function makeEnv() { + const afterResolveHook = { tapPromise: jest.fn() }; + const createModuleHook = { tapPromise: jest.fn() }; + + const normalModuleFactory: any = { + hooks: { + factorize: { tapPromise: jest.fn() }, + createModule: createModuleHook, + afterResolve: afterResolveHook, + }, + }; + + const mockResolver = { + withOptions: jest.fn().mockReturnThis(), + resolve: jest.fn((_ignored, _ctx, _req, _rctx, cb) => + cb(null, '/abs/node_modules/next/dist/compiled/react/index.js'), + ), + }; + + const mockCompilation: any = { + dependencyFactories: new Map(), + hooks: { + finishModules: { + tap: jest.fn(), + tapAsync: jest.fn((_name, cb) => cb([], jest.fn())), + }, + additionalTreeRuntimeRequirements: { tap: jest.fn() }, + }, + resolverFactory: { get: jest.fn().mockReturnValue(mockResolver) }, + addRuntimeModule: jest.fn(), + addModule: jest.fn(), + moduleGraph: { getModule: jest.fn() }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + compiler: { context: '/proj' }, + }; + + const compiler: any = { + context: '/proj', + hooks: { + thisCompilation: { + tap: jest.fn((_name, cb) => + cb(mockCompilation, { normalModuleFactory }), + ), + }, + }, + }; + + return { + compiler, + mockCompilation, + normalModuleFactory, + afterResolveHook, + createModuleHook, + mockResolver, + }; + } + + it('does not rewrite import when cfg.import === false', async () => { + const env = makeEnv(); + const plugin = new ConsumeSharedPlugin({ + shareScope: 'default', + experiments: { aliasConsumption: true } as any, + consumes: { + react: { + import: false, + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-A', + layer: 'layer-A', + }, + }, + } as any); + + // Configure unresolved consume so alias fallback path runs + const { + resolveMatchedConfigs, + } = require('../../../../src/lib/sharing/resolveMatchedConfigs'); + const cfg = { + request: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-A', + layer: 'layer-A', + import: false, + }; + (resolveMatchedConfigs as jest.Mock).mockResolvedValue({ + resolved: new Map(), + unresolved: new Map([['(layer-A)react', cfg]]), + prefixed: new Map(), + }); + + mockGetDescriptionFile.mockImplementation((_fs, _dir, _names, cb) => + cb(null, null), + ); + + plugin.apply(env.compiler); + + // afterResolve should map the resource to our consume config without changing import=false + const afterResolveCb = env.afterResolveHook.tapPromise.mock.calls[0][1]; + await afterResolveCb({ + request: 'react', + contextInfo: { issuerLayer: 'layer-A' }, + createData: { + resource: '/abs/node_modules/next/dist/compiled/react/index.js', + }, + }); + + // createModule should create a ConsumeSharedModule with options.import strictly false + const createModuleCb = + env.normalModuleFactory.hooks.createModule.tapPromise.mock.calls[0][1]; + const result: any = await createModuleCb( + { resource: '/abs/node_modules/next/dist/compiled/react/index.js' }, + { context: '/proj', dependencies: [{}] }, + ); + + expect(result).toBeDefined(); + expect(result.options).toBeDefined(); + // Must not be rewritten to a string fallback path + expect(typeof result.options.import === 'string').toBe(false); + // Either undefined or false is acceptable for remote-only consumes + expect([undefined, false]).toContain(result.options.import); + }); +}); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.alias-fallback-layer.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.alias-fallback-layer.test.ts new file mode 100644 index 00000000000..358d5ae8152 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.alias-fallback-layer.test.ts @@ -0,0 +1,218 @@ +/* + * @jest-environment node + */ + +import { + ConsumeSharedPlugin, + mockGetDescriptionFile, +} from './shared-test-utils'; + +jest.setTimeout(15000); + +describe('ConsumeSharedPlugin - alias fallback respects issuerLayer', () => { + function makeEnv() { + const afterResolveHook = { tapPromise: jest.fn() }; + const createModuleHook = { tapPromise: jest.fn() }; + + const normalModuleFactory: any = { + hooks: { + factorize: { tapPromise: jest.fn() }, + createModule: createModuleHook, + afterResolve: afterResolveHook, + }, + }; + + const mockResolver = { + withOptions: jest.fn().mockReturnThis(), + resolve: jest.fn((_ignored, _ctx, _req, _rctx, cb) => + cb(null, '/abs/node_modules/next/dist/compiled/react/index.js'), + ), + }; + + const mockCompilation: any = { + dependencyFactories: new Map(), + hooks: { + finishModules: { + tap: jest.fn(), + tapAsync: jest.fn((_name, cb) => cb([], jest.fn())), + }, + additionalTreeRuntimeRequirements: { tap: jest.fn() }, + }, + resolverFactory: { get: jest.fn().mockReturnValue(mockResolver) }, + addRuntimeModule: jest.fn(), + addModule: jest.fn(), + moduleGraph: { getModule: jest.fn() }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + compiler: { context: '/proj' }, + }; + + const compiler: any = { + context: '/proj', + hooks: { + thisCompilation: { + tap: jest.fn((_name, cb) => + cb(mockCompilation, { normalModuleFactory }), + ), + }, + }, + }; + + return { + compiler, + mockCompilation, + normalModuleFactory, + afterResolveHook, + createModuleHook, + mockResolver, + }; + } + + it('skips mapping when cfg.issuerLayer mismatches active issuerLayer', async () => { + const env = makeEnv(); + // Configure plugin with aliasConsumption on + const plugin = new ConsumeSharedPlugin({ + shareScope: 'default', + experiments: { aliasConsumption: true } as any, + consumes: { + // define a consume entry in a different layer + react: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-B', + layer: 'layer-B', + }, + }, + } as any); + + // Patch internal unresolvedConsumes via resolveMatchedConfigs mock + const { + resolveMatchedConfigs, + } = require('../../../../src/lib/sharing/resolveMatchedConfigs'); + const cfgB = { + request: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-B', + layer: 'layer-B', + }; + (resolveMatchedConfigs as jest.Mock).mockResolvedValue({ + resolved: new Map(), + unresolved: new Map([['(layer-B)react', cfgB]]), + prefixed: new Map(), + }); + + // Ensure getDescriptionFile resolves immediately (no package.json walk needed) + mockGetDescriptionFile.mockImplementation((_fs, _dir, _names, cb) => + cb(null, null), + ); + + plugin.apply(env.compiler); + + // Extract afterResolve callback + expect(env.afterResolveHook.tapPromise).toHaveBeenCalled(); + const afterResolveCb = env.afterResolveHook.tapPromise.mock.calls[0][1]; + + // Call afterResolve for a module issued in layer-A + await afterResolveCb({ + request: 'react', + contextInfo: { issuerLayer: 'layer-A' }, + createData: { + resource: '/abs/node_modules/next/dist/compiled/react/index.js', + }, + }); + + // Now simulate createModule; because mapping was skipped, createModule should not create ConsumeSharedModule + const createModuleCb = + env.normalModuleFactory.hooks.createModule.tapPromise.mock.calls[0][1]; + const result = await createModuleCb( + { resource: '/abs/node_modules/next/dist/compiled/react/index.js' }, + { context: '/proj', dependencies: [{}] }, + ); + expect(result).toBeUndefined(); + }); + + it('maps only the cfg with matching issuerLayer when multiple exist', async () => { + const env = makeEnv(); + const plugin = new ConsumeSharedPlugin({ + shareScope: 'default', + experiments: { aliasConsumption: true } as any, + consumes: { + reactA: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-A', + layer: 'layer-A', + }, + reactB: { + import: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-B', + layer: 'layer-B', + }, + }, + } as any); + + const { + resolveMatchedConfigs, + } = require('../../../../src/lib/sharing/resolveMatchedConfigs'); + const cfgA = { + request: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-A', + layer: 'layer-A', + }; + const cfgB = { + request: 'react', + shareKey: 'react', + shareScope: 'default', + requiredVersion: false, + issuerLayer: 'layer-B', + layer: 'layer-B', + }; + (resolveMatchedConfigs as jest.Mock).mockResolvedValue({ + resolved: new Map(), + unresolved: new Map([ + ['(layer-A)react', cfgA], + ['(layer-B)react', cfgB], + ]), + prefixed: new Map(), + }); + + mockGetDescriptionFile.mockImplementation((_fs, _dir, _names, cb) => + cb(null, null), + ); + + plugin.apply(env.compiler); + const afterResolveCb = env.afterResolveHook.tapPromise.mock.calls[0][1]; + await afterResolveCb({ + request: 'react', + contextInfo: { issuerLayer: 'layer-A' }, + createData: { + resource: '/abs/node_modules/next/dist/compiled/react/index.js', + }, + }); + + // Now createModule should create the consume-shared module (mocked) + const createModuleCb = + env.normalModuleFactory.hooks.createModule.tapPromise.mock.calls[0][1]; + const result = await createModuleCb( + { resource: '/abs/node_modules/next/dist/compiled/react/index.js' }, + { context: '/proj', dependencies: [{}] }, + ); + // The mocked factory returns a plain object; we just assert it returned something truthy + expect(result).toBeDefined(); + // And ensure our resolver was asked + expect(env.mockResolver.resolve).toHaveBeenCalled(); + }); +}); diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts index 130fe7b73cf..f45d33d0162 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts @@ -465,6 +465,8 @@ describe('ProvideSharedPlugin', () => { }); }); + // Stage 3 (alias-aware) follows direct matching semantics for request filters. + describe('layer matching logic', () => { it('should match modules with same layer', () => { const plugin = new ProvideSharedPlugin({ diff --git a/packages/enhanced/tsconfig.lib.json b/packages/enhanced/tsconfig.lib.json index e81b9104e63..95ecafa2b76 100644 --- a/packages/enhanced/tsconfig.lib.json +++ b/packages/enhanced/tsconfig.lib.json @@ -7,7 +7,6 @@ }, "include": ["src/**/*.ts"], "exclude": [ - "jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts", "dist/**", diff --git a/packages/enhanced/tsconfig.spec.json b/packages/enhanced/tsconfig.spec.json index 9b2a121d114..7667e31251b 100644 --- a/packages/enhanced/tsconfig.spec.json +++ b/packages/enhanced/tsconfig.spec.json @@ -5,10 +5,5 @@ "module": "commonjs", "types": ["jest", "node"] }, - "include": [ - "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] + "include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/packages/sdk/src/types/plugins/SharePlugin.ts b/packages/sdk/src/types/plugins/SharePlugin.ts index 2d75a67f765..8f8a9f2b56c 100644 --- a/packages/sdk/src/types/plugins/SharePlugin.ts +++ b/packages/sdk/src/types/plugins/SharePlugin.ts @@ -75,6 +75,10 @@ export interface SharedConfig { * Version of the provided module. Will replace lower matching versions, but not higher. */ version?: false | string; + /** + * Disable reconstructing node_modules lookup for this shared item when resolving. + */ + nodeModulesReconstructedLookup?: boolean; } export declare class SharePlugin { diff --git a/scripts/run-e2e-manifest-dev.sh b/scripts/run-e2e-manifest-dev.sh deleted file mode 100755 index 99d6ab963ba..00000000000 --- a/scripts/run-e2e-manifest-dev.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" -cd "$ROOT_DIR" - -# Ensure devtools postinstall hook is skipped like in CI. -export SKIP_DEVTOOLS_POSTINSTALL="${SKIP_DEVTOOLS_POSTINSTALL:-true}" - -echo "[e2e-manifest-dev] Installing workspace dependencies" -pnpm install - -echo "[e2e-manifest-dev] Ensuring Cypress binary is installed" -npx cypress install - -echo "[e2e-manifest-dev] Building all tagged packages" -npx nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache - -echo "[e2e-manifest-dev] Checking if manifest host is affected" -if ! node tools/scripts/ci-is-affected.mjs --appName=manifest-webpack-host; then - echo "[e2e-manifest-dev] Manifest host not affected; skipping E2E run" - exit 0 -fi - -cleanup() { - echo "[e2e-manifest-dev] Cleaning up background processes and ports" - npx kill-port 3013 3009 3010 3011 3012 4001 >/dev/null 2>&1 || true - if [[ -n "${DEV_PID:-}" ]] && kill -0 "$DEV_PID" >/dev/null 2>&1; then - kill "$DEV_PID" >/dev/null 2>&1 || true - fi -} - -trap cleanup EXIT - -echo "[e2e-manifest-dev] Starting manifest dev servers" -export NX_SKIP_NX_CACHE=1 -pnpm run app:manifest:dev >/tmp/e2e-manifest-dev.log 2>&1 & -DEV_PID=$! - -echo "[e2e-manifest-dev] Waiting for required ports" -npx wait-on tcp:3009 tcp:3012 http://127.0.0.1:4001/ - -echo "[e2e-manifest-dev] Running manifest host Cypress suites" -TIMEOUT_SECONDS=300 -if ! timeout "$TIMEOUT_SECONDS" npx nx run-many --target=e2e --projects=manifest-webpack-host --parallel=2 --skip-nx-cache; then - echo "[e2e-manifest-dev] E2E run timed out after ${TIMEOUT_SECONDS}s" >&2 - cleanup - exit 1 -fi - -echo "[e2e-manifest-dev] Completed successfully"