diff --git a/packages/ngtools/webpack/src/paths-plugin.ts b/packages/ngtools/webpack/src/paths-plugin.ts index 60f845ccd140..7b050963f227 100644 --- a/packages/ngtools/webpack/src/paths-plugin.ts +++ b/packages/ngtools/webpack/src/paths-plugin.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import { CompilerOptions } from 'typescript'; + import type { Configuration } from 'webpack'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -16,6 +17,9 @@ export interface TypeScriptPathsPluginOptions extends Pick['resolver'], undefined>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type DoResolveValue = any; + interface PathPattern { starIndex: number; prefix: string; @@ -154,8 +158,29 @@ export class TypeScriptPathsPlugin { // For example, if the first one resolves, any others are not needed and do not need // to be created. const replacements = findReplacements(originalRequest, this.patterns); + const basePath = this.baseUrl ?? ''; + + const attemptResolveRequest = (request: DoResolveValue): Promise => { + return new Promise((resolve, reject) => { + resolver.doResolve( + target, + request, + '', + resolveContext, + (error: Error | null, result: DoResolveValue) => { + if (error) { + reject(error); + } else if (result) { + resolve(result); + } else { + resolve(null); + } + }, + ); + }); + }; - const tryResolve = () => { + const tryNextReplacement = () => { const next = replacements.next(); if (next.done) { callback(); @@ -163,31 +188,45 @@ export class TypeScriptPathsPlugin { return; } - const potentialRequest = { + const targetPath = path.resolve(basePath, next.value); + // If there is no extension. i.e. the target does not refer to an explicit + // file, then this is a candidate for module/package resolution. + const canBeModule = path.extname(targetPath) === ''; + + // Resolution in the target location, preserving the original request. + // This will work with the `resolve-in-package` resolution hook, supporting + // package exports for e.g. locally-built APF libraries. + const potentialRequestAsPackage = { ...request, - request: path.resolve(this.baseUrl ?? '', next.value), + path: targetPath, typescriptPathMapped: true, }; - resolver.doResolve( - target, - potentialRequest, - '', - resolveContext, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error: Error | null, result: any) => { - if (error) { - callback(error); - } else if (result) { - callback(undefined, result); - } else { - tryResolve(); - } - }, - ); + // Resolution in the original callee location, but with the updated request + // to point to the mapped target location. + const potentialRequestAsFile = { + ...request, + request: targetPath, + typescriptPathMapped: true, + }; + + let resultPromise = attemptResolveRequest(potentialRequestAsFile); + + // If the request can be a module, we configure the resolution to try package/module + // resolution if the file resolution did not have a result. + if (canBeModule) { + resultPromise = resultPromise.then( + (result) => result ?? attemptResolveRequest(potentialRequestAsPackage), + ); + } + + // If we have a result, complete. If not, and no error, try the next replacement. + resultPromise + .then((res) => (res === null ? tryNextReplacement() : callback(undefined, res))) + .catch((error) => callback(error)); }; - tryResolve(); + tryNextReplacement(); }, ); } diff --git a/tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts b/tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts new file mode 100644 index 000000000000..02066a53070a --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { createDir, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await ng('generate', 'library', 'mylib'); + await createLibraryEntryPoint('secondary', 'SecondaryModule', 'index.ts'); + await createLibraryEntryPoint('another', 'AnotherModule', 'index.ts'); + + // Scenario #1 where we use wildcard path mappings for secondary entry-points. + await updateJsonFile('tsconfig.json', (json) => { + json.compilerOptions.paths = { 'mylib': ['dist/mylib'], 'mylib/*': ['dist/mylib/*'] }; + }); + + await writeFile( + 'src/app/app.module.ts', + ` + import {NgModule} from '@angular/core'; + import {BrowserModule} from '@angular/platform-browser'; + import {SecondaryModule} from 'mylib/secondary'; + import {AnotherModule} from 'mylib/another'; + + import {AppComponent} from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + SecondaryModule, + AnotherModule, + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + ); + + await ng('build', 'mylib'); + await ng('build'); + + // Scenario #2 where we don't use wildcard path mappings. + await updateJsonFile('tsconfig.json', (json) => { + json.compilerOptions.paths = { + 'mylib': ['dist/mylib'], + 'mylib/secondary': ['dist/mylib/secondary'], + 'mylib/another': ['dist/mylib/another'], + }; + }); + + await ng('build'); +} + +async function createLibraryEntryPoint(name: string, moduleName: string, entryFileName: string) { + await createDir(`projects/mylib/${name}`); + await writeFile( + `projects/mylib/${name}/${entryFileName}`, + ` + import {NgModule} from '@angular/core'; + + @NgModule({}) + export class ${moduleName} {} + `, + ); + + await writeFile( + `projects/mylib/${name}/ng-package.json`, + JSON.stringify({ + lib: { + entryFile: entryFileName, + }, + }), + ); +}