diff --git a/e2e/react-core/src/react-module-federation.test.ts b/e2e/react-core/src/react-module-federation.test.ts index f03f99fa2c513..600dd5473edd7 100644 --- a/e2e/react-core/src/react-module-federation.test.ts +++ b/e2e/react-core/src/react-module-federation.test.ts @@ -2,12 +2,16 @@ import { stripIndents } from '@nx/devkit'; import { checkFilesExist, cleanupProject, + killProcessAndPorts, newProject, readJson, runCLI, runCLIAsync, + runCommandUntil, + runE2ETests, uniq, updateFile, + updateJson, } from '@nx/e2e/utils'; import { join } from 'path'; @@ -139,6 +143,145 @@ describe('React Module Federation', () => { expect(buildOutput).toContain('Successfully ran target build'); }, 500_000); + it('should support different versions workspace libs for host and remote', async () => { + const shell = uniq('shell'); + const remote = uniq('remote'); + const lib = uniq('lib'); + + runCLI( + `generate @nx/react:host ${shell} --remotes=${remote} --no-interactive --projectNameAndRootFormat=as-provided` + ); + + runCLI( + `generate @nx/js:lib ${lib} --importPath=@acme/${lib} --publishable=true --no-interactive --projectNameAndRootFormat=as-provided` + ); + + updateFile( + `${lib}/src/lib/${lib}.ts`, + stripIndents` + export const version = '0.0.1'; + ` + ); + + updateJson(`${lib}/package.json`, (json) => { + return { + ...json, + version: '0.0.1', + }; + }); + + // Update host to use the lib + updateFile( + `${shell}/src/app/app.tsx`, + ` + import * as React from 'react'; + + import NxWelcome from './nx-welcome'; + import { version } from '@acme/${lib}'; + import { Link, Route, Routes } from 'react-router-dom'; + + const About = React.lazy(() => import('${remote}/Module')); + + export function App() { + return ( + +
+ Lib version: { version } +
+ + + } /> + + } /> + +
+ ); + } + + export default App;` + ); + + // Update remote to use the lib + updateFile( + `${remote}/src/app/app.tsx`, + `// eslint-disable-next-line @typescript-eslint/no-unused-vars + + import styles from './app.module.css'; + import { version } from '@acme/${lib}'; + + import NxWelcome from './nx-welcome'; + + export function App() { + return ( + +
+ Lib version: { version } + +
+ ); + } + + export default App;` + ); + + // update remote e2e test to check the version + updateFile( + `${remote}-e2e/src/e2e/app.cy.ts`, + `describe('${remote}', () => { + beforeEach(() => cy.visit('/')); + + it('should check the lib version', () => { + cy.get('div.remote').contains('Lib version: 0.0.1'); + }); + }); + ` + ); + + // update shell e2e test to check the version + updateFile( + `${shell}-e2e/src/e2e/app.cy.ts`, + ` + describe('${shell}', () => { + beforeEach(() => cy.visit('/')); + + it('should check the lib version', () => { + cy.get('div.home').contains('Lib version: 0.0.1'); + }); + }); + ` + ); + + if (runE2ETests()) { + // test remote e2e + const remoteE2eResults = runCLI(`e2e ${remote}-e2e --no-watch --verbose`); + expect(remoteE2eResults).toContain('All specs passed!'); + + // test shell e2e + // serve remote first + const remotePort = 4201; + const remoteProcess = await runCommandUntil( + `serve ${remote} --no-watch --verbose`, + (output) => { + return output.includes( + `Web Development Server is listening at http://localhost:${remotePort}/` + ); + } + ); + const shellE2eResults = runCLI(`e2e ${shell}-e2e --no-watch --verbose`); + expect(shellE2eResults).toContain('All specs passed!'); + + await killProcessAndPorts(remoteProcess.pid, remotePort); + } + }, 500_000); + function readPort(appName: string): number { const config = readJson(join('apps', appName, 'project.json')); return config.targets.serve.options.port; diff --git a/packages/angular/src/utils/mf/utils.ts b/packages/angular/src/utils/mf/utils.ts index 101142ad5796e..b63c5e061dec2 100644 --- a/packages/angular/src/utils/mf/utils.ts +++ b/packages/angular/src/utils/mf/utils.ts @@ -124,7 +124,9 @@ export async function getModuleFederationConfig( }); const sharedDependencies = { - ...sharedLibraries.getLibraries(), + ...sharedLibraries.getLibraries( + projectGraph.nodes[mfConfig.name].data.root + ), ...npmPackages, }; diff --git a/packages/react/src/module-federation/utils.ts b/packages/react/src/module-federation/utils.ts index ce46bb2f8a05a..7775123b1dce6 100644 --- a/packages/react/src/module-federation/utils.ts +++ b/packages/react/src/module-federation/utils.ts @@ -84,7 +84,7 @@ export async function getModuleFederationConfig( const npmPackages = sharePackages(dependencies.npmPackages); const sharedDependencies = { - ...sharedLibraries.getLibraries(), + ...sharedLibraries.getLibraries(project.root), ...npmPackages, }; diff --git a/packages/webpack/src/utils/module-federation/models/index.ts b/packages/webpack/src/utils/module-federation/models/index.ts index 5d0365c213988..36653056fbffc 100644 --- a/packages/webpack/src/utils/module-federation/models/index.ts +++ b/packages/webpack/src/utils/module-federation/models/index.ts @@ -1,6 +1,7 @@ import type { NormalModuleReplacementPlugin } from 'webpack'; export type ModuleFederationLibrary = { type: string; name: string }; + export type WorkspaceLibrary = { name: string; root: string; @@ -9,7 +10,10 @@ export type WorkspaceLibrary = { export type SharedWorkspaceLibraryConfig = { getAliases: () => Record; - getLibraries: (eager?: boolean) => Record; + getLibraries: ( + projectRoot: string, + eager?: boolean + ) => Record; getReplacementPlugin: () => NormalModuleReplacementPlugin; }; diff --git a/packages/webpack/src/utils/module-federation/share.spec.ts b/packages/webpack/src/utils/module-federation/share.spec.ts index d7cb824f211ed..21cd14e61b73f 100644 --- a/packages/webpack/src/utils/module-federation/share.spec.ts +++ b/packages/webpack/src/utils/module-federation/share.spec.ts @@ -50,7 +50,7 @@ describe('MF Share Utils', () => { expect(sharedLibraries.getAliases()['@myorg/shared']).toContain( 'libs/shared/src/index.ts' ); - expect(sharedLibraries.getLibraries()).toEqual({ + expect(sharedLibraries.getLibraries('libs/shared')).toEqual({ '@myorg/shared': { eager: undefined, requiredVersion: false, @@ -60,9 +60,7 @@ describe('MF Share Utils', () => { it('should handle path mappings with wildcards correctly in non-buildable libraries', () => { // ARRANGE - jest - .spyOn(fs, 'existsSync') - .mockImplementation((file: string) => !file?.endsWith('package.json')); + jest.spyOn(fs, 'existsSync').mockImplementation((file: string) => true); jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ '@myorg/shared': ['/libs/shared/src/index.ts'], '@myorg/shared/*': ['/libs/shared/src/lib/*'], @@ -78,7 +76,7 @@ describe('MF Share Utils', () => { expect(sharedLibraries.getAliases()['@myorg/shared']).toContain( 'libs/shared/src/index.ts' ); - expect(sharedLibraries.getLibraries()).toEqual({ + expect(sharedLibraries.getLibraries('libs/shared')).toEqual({ '@myorg/shared': { eager: undefined, requiredVersion: false, @@ -98,7 +96,7 @@ describe('MF Share Utils', () => { // ASSERT expect(sharedLibraries.getAliases()).toEqual({}); - expect(sharedLibraries.getLibraries()).toEqual({}); + expect(sharedLibraries.getLibraries('libs/shared')).toEqual({}); }); }); @@ -371,6 +369,78 @@ describe('MF Share Utils', () => { ).not.toThrow(); }); }); + + it('should using shared library version from root package.json if available', () => { + // ARRANGE + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest + .spyOn(nxFileutils, 'readJsonFile') + .mockImplementation((file: string) => { + if (file.endsWith('package.json')) { + return { + dependencies: { + '@myorg/shared': '1.0.0', + }, + }; + } + }); + + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ + '@myorg/shared': ['/libs/shared/src/index.ts'], + '@myorg/shared/*': ['/libs/shared/src/lib/*'], + }); + + // ACT + const sharedLibraries = shareWorkspaceLibraries( + [{ name: 'shared', root: 'libs/shared', importKey: '@myorg/shared' }], + '/' + ); + + // ASSERT + expect(sharedLibraries.getLibraries('libs/shared')).toEqual({ + '@myorg/shared': { + eager: undefined, + requiredVersion: '1.0.0', + singleton: true, + }, + }); + }); + + it('should use shared library version from library package.json if project package.json does not have it', () => { + // ARRANGE + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest + .spyOn(nxFileutils, 'readJsonFile') + .mockImplementation((file: string) => { + if (file.endsWith('libs/shared/package.json')) { + return { + version: '1.0.0', + }; + } else { + return {}; + } + }); + + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ + '@myorg/shared': ['/libs/shared/src/index.ts'], + '@myorg/shared/*': ['/libs/shared/src/lib/*'], + }); + + // ACT + const sharedLibraries = shareWorkspaceLibraries( + [{ name: 'shared', root: 'libs/shared', importKey: '@myorg/shared' }], + null + ); + + // ASSERT + expect(sharedLibraries.getLibraries('libs/shared')).toEqual({ + '@myorg/shared': { + eager: undefined, + requiredVersion: '1.0.0', + singleton: true, + }, + }); + }); }); function createMockedFSForNestedEntryPoints() { diff --git a/packages/webpack/src/utils/module-federation/share.ts b/packages/webpack/src/utils/module-federation/share.ts index ceeba7cc55238..b0188c909564d 100644 --- a/packages/webpack/src/utils/module-federation/share.ts +++ b/packages/webpack/src/utils/module-federation/share.ts @@ -11,20 +11,29 @@ import { collectPackageSecondaryEntryPoints, collectWorkspaceLibrarySecondaryEntryPoints, } from './secondary-entry-points'; -import { type ProjectGraph, workspaceRoot, logger } from '@nx/devkit'; +import { + type ProjectGraph, + workspaceRoot, + logger, + readJsonFile, + ProjectGraphProjectNode, + joinPathFragments, +} from '@nx/devkit'; +import { existsSync } from 'fs'; +import type { PackageJson } from 'nx/src/utils/package-json'; /** * Build an object of functions to be used with the ModuleFederationPlugin to * share Nx Workspace Libraries between Hosts and Remotes. * - * @param libraries - The Nx Workspace Libraries to share + * @param workspaceLibs - The Nx Workspace Libraries to share * @param tsConfigPath - The path to TS Config File that contains the Path Mappings for the Libraries */ export function shareWorkspaceLibraries( - libraries: WorkspaceLibrary[], + workspaceLibs: WorkspaceLibrary[], tsConfigPath = process.env.NX_TSCONFIG_PATH ?? getRootTsConfigPath() ): SharedWorkspaceLibraryConfig { - if (!libraries) { + if (!workspaceLibs) { return getEmptySharedLibrariesConfig(); } @@ -35,7 +44,7 @@ export function shareWorkspaceLibraries( const pathMappings: { name: string; path: string }[] = []; for (const [key, paths] of Object.entries(tsconfigPathAliases)) { - const library = libraries.find((lib) => lib.importKey === key); + const library = workspaceLibs.find((lib) => lib.importKey === key); if (!library) { continue; } @@ -66,14 +75,55 @@ export function shareWorkspaceLibraries( (aliases, library) => ({ ...aliases, [library.name]: library.path }), {} ), - getLibraries: (eager?: boolean): Record => - pathMappings.reduce( - (libraries, library) => ({ + getLibraries: ( + projectRoot: string, + eager?: boolean + ): Record => { + let pkgJson: PackageJson = null; + if ( + projectRoot && + existsSync( + joinPathFragments(workspaceRoot, projectRoot, 'package.json') + ) + ) { + pkgJson = readJsonFile( + joinPathFragments(workspaceRoot, projectRoot, 'package.json') + ); + } + return pathMappings.reduce((libraries, library) => { + // Check to see if the library version is declared in the app's package.json + let version = pkgJson?.dependencies?.[library.name]; + if (!version && workspaceLibs.length > 0) { + const workspaceLib = workspaceLibs.find( + (lib) => lib.importKey === library.name + ); + + const libPackageJsonPath = workspaceLib + ? join(workspaceLib.root, 'package.json') + : null; + if (libPackageJsonPath && existsSync(libPackageJsonPath)) { + pkgJson = readJsonFile(libPackageJsonPath); + + if (pkgJson) { + version = pkgJson.version; + } + } + } + + return { ...libraries, - [library.name]: { requiredVersion: false, eager }, - }), - {} as Record - ), + [library.name]: { + ...(version + ? { + requiredVersion: version, + singleton: true, + } + : { requiredVersion: false }), + eager, + }, + }; + }, {} as Record); + }, getReplacementPlugin: () => new webpack.NormalModuleReplacementPlugin(/./, (req) => { if (!req.request.startsWith('.')) {