Skip to content

Commit

Permalink
fix(@angular-devkit/build-angular): properly handle locally-built APF…
Browse files Browse the repository at this point in the history
… v14 libraries

Locally-built APF v14 libraries should be resolved properly. Webpack
currently does not resolve them (in e.g. `dist/`) because the local
distribution folders are not marked as module roots, causing Webpack
to never hit the `module`/`raw-module` resolution hooks and therefore
skipping package exports resolution and breaking secondary entry-points
from being resolved properly (when bundling).

We fix this by also attempting to resolve path mappings as modules,
allowing for Webpacks `resolve-in-package` hooks to be activated. These
hooks support the `exports` field and therefore APF v14 secondary
entry-points which are not necessarily inside a Webpack resolve
`modules:` root (but e.g. in `dist/`)

(cherry picked from commit ba93117)
  • Loading branch information
devversion authored and dgp1130 committed May 11, 2022
1 parent b0c3ced commit ac1383f
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 20 deletions.
79 changes: 59 additions & 20 deletions packages/ngtools/webpack/src/paths-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,9 @@ export interface TypeScriptPathsPluginOptions extends Pick<CompilerOptions, 'pat
// Extract Resolver type from Webpack types since it is not directly exported
type Resolver = Exclude<Exclude<Configuration['resolve'], undefined>['resolver'], undefined>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DoResolveValue = any;

interface PathPattern {
starIndex: number;
prefix: string;
Expand Down Expand Up @@ -154,40 +158,75 @@ 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<DoResolveValue | null> => {
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();

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();
},
);
}
Expand Down
84 changes: 84 additions & 0 deletions tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts
Original file line number Diff line number Diff line change
@@ -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,
},
}),
);
}

0 comments on commit ac1383f

Please sign in to comment.