Skip to content

Commit 8eacb50

Browse files
committed
feat(@ngtools/webpack): support import syntax for loadChildren with ViewEngine
This feature ONLY matches the format below: ``` loadChildren: () => import('IMPORT_STRING').then(m => m.EXPORT_NAME) ``` It will not match nor alter variations, for instance: - not using arrow functions - not using `m` as the module argument - using `await` instead of `then` - using a default export (angular/angular#11402) The only parts that can change are the ones in caps: IMPORT_STRING and EXPORT_NAME.
1 parent edbace1 commit 8eacb50

File tree

5 files changed

+286
-3
lines changed

5 files changed

+286
-3
lines changed

packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
import { tags } from '@angular-devkit/core';
99
import * as CopyWebpackPlugin from 'copy-webpack-plugin';
1010
import * as path from 'path';
11-
import { Configuration, HashedModuleIdsPlugin, Output, debug } from 'webpack';
11+
import {
12+
Configuration,
13+
ContextReplacementPlugin,
14+
HashedModuleIdsPlugin,
15+
Output,
16+
debug,
17+
} from 'webpack';
1218
import { AssetPatternClass } from '../../../browser/schema';
1319
import { BundleBudgetPlugin } from '../../plugins/bundle-budget';
1420
import { CleanCssWebpackPlugin } from '../../plugins/cleancss-webpack-plugin';
@@ -345,6 +351,12 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
345351
...extraMinimizers,
346352
],
347353
},
348-
plugins: extraPlugins,
354+
plugins: [
355+
// Always replace the context for the System.import in angular/core to prevent warnings.
356+
// https://github.com/angular/angular/issues/11580
357+
// With VE the correct context is added in @ngtools/webpack, but Ivy doesn't need it at all.
358+
new ContextReplacementPlugin(/\@angular(\\|\/)core(\\|\/)/),
359+
...extraPlugins,
360+
],
349361
};
350362
}

packages/ngtools/webpack/src/angular_compiler_plugin.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
exportLazyModuleMap,
4747
exportNgFactory,
4848
findResources,
49+
importFactory,
4950
registerLocaleData,
5051
removeDecorators,
5152
replaceBootstrap,
@@ -108,6 +109,7 @@ export interface AngularCompilerPluginOptions {
108109
// When using Ivy, the string syntax is not supported at all. Thus we shouldn't attempt that.
109110
// This option is also used for when the compilation doesn't need this sort of processing at all.
110111
discoverLazyRoutes?: boolean;
112+
importFactories?: boolean;
111113

112114
// added to the list of lazy routes
113115
additionalLazyModules?: { [module: string]: string };
@@ -140,6 +142,7 @@ export class AngularCompilerPlugin {
140142
private _moduleResolutionCache: ts.ModuleResolutionCache;
141143
private _resourceLoader?: WebpackResourceLoader;
142144
private _discoverLazyRoutes = true;
145+
private _importFactories = false;
143146
// Contains `moduleImportPath#exportName` => `fullModulePath`.
144147
private _lazyRoutes: LazyRouteMap = {};
145148
private _tsConfigPath: string;
@@ -298,7 +301,12 @@ export class AngularCompilerPlugin {
298301
this._platformTransformers = options.platformTransformers;
299302
}
300303

301-
if (options.discoverLazyRoutes !== undefined) {
304+
// Determine if lazy route discovery via Compiler CLI private API should be attempted.
305+
if (this._compilerOptions.enableIvy) {
306+
// Never try to discover lazy routes with Ivy.
307+
this._discoverLazyRoutes = false;
308+
} else if (options.discoverLazyRoutes !== undefined) {
309+
// The default is to discover routes, but it can be overriden.
302310
this._discoverLazyRoutes = options.discoverLazyRoutes;
303311
}
304312

@@ -318,6 +326,11 @@ export class AngularCompilerPlugin {
318326
);
319327
}
320328

329+
if (!this._compilerOptions.enableIvy && options.importFactories === true) {
330+
// Only transform imports to use factories with View Engine.
331+
this._importFactories = true;
332+
}
333+
321334
// Default ContextElementDependency to the one we can import from here.
322335
// Failing to use the right ContextElementDependency will throw the error below:
323336
// "No module factory available for dependency type: ContextElementDependency"
@@ -896,6 +909,10 @@ export class AngularCompilerPlugin {
896909
} else {
897910
// Remove unneeded angular decorators.
898911
this._transformers.push(removeDecorators(isAppPath, getTypeChecker));
912+
// Import ngfactory in loadChildren import syntax
913+
if (this._importFactories) {
914+
this._transformers.push(importFactory(msg => this._warnings.push(msg)));
915+
}
899916
}
900917

901918
if (this._platformTransformers !== null) {
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import * as ts from 'typescript';
9+
10+
11+
/**
12+
* Given this original source code:
13+
*
14+
* import { NgModule } from '@angular/core';
15+
* import { Routes, RouterModule } from '@angular/router';
16+
*
17+
* const routes: Routes = [{
18+
* path: 'lazy',
19+
* loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule).
20+
* }];
21+
*
22+
* @NgModule({
23+
* imports: [RouterModule.forRoot(routes)],
24+
* exports: [RouterModule]
25+
* })
26+
* export class AppRoutingModule { }
27+
*
28+
* NGC (View Engine) will process it into:
29+
*
30+
* import { Routes } from '@angular/router';
31+
* const ɵ0 = () => import('./lazy/lazy.module').then(m => m.LazyModule);
32+
* const routes: Routes = [{
33+
* path: 'lazy',
34+
* loadChildren: ɵ0
35+
* }];
36+
* export class AppRoutingModule {
37+
* }
38+
* export { ɵ0 };
39+
*
40+
* The importFactory transformation will only see the AST after it is process by NGC.
41+
* You can confirm this with the code below:
42+
*
43+
* const res = ts.createPrinter().printNode(ts.EmitHint.Unspecified, sourceFile, sourceFile);
44+
* console.log(`### Original source: \n${sourceFile.text}\n###`);
45+
* console.log(`### Current source: \n${currentText}\n###`);
46+
*
47+
* At this point it doesn't yet matter what the target (ES5/ES2015/etc) is, so the original
48+
* constructs, like `class` and arrow functions, still remain.
49+
*
50+
*/
51+
52+
export function importFactory(
53+
warningCb: (warning: string) => void,
54+
): ts.TransformerFactory<ts.SourceFile> {
55+
return (context: ts.TransformationContext) => {
56+
return (sourceFile: ts.SourceFile) => {
57+
const warning = `
58+
Found 'loadChildren' with a non-string syntax in ${sourceFile.fileName} but could not transform it.
59+
Make sure it matches the format below:
60+
61+
loadChildren: () => import('IMPORT_STRING').then(m => m.EXPORT_NAME)
62+
63+
Please note that only IMPORT_STRING and EXPORT_NAME can be replaced in this format.`;
64+
65+
const emitWarning = () => warningCb(warning);
66+
const visitVariableStatement: ts.Visitor = (node: ts.Node) => {
67+
if (ts.isVariableDeclaration(node)) {
68+
return replaceImport(node, context, emitWarning);
69+
}
70+
71+
return ts.visitEachChild(node, visitVariableStatement, context);
72+
};
73+
74+
const visitToplevelNodes: ts.Visitor = (node: ts.Node) => {
75+
// We only care about finding variable declarations, which are found in this structure:
76+
// VariableStatement -> VariableDeclarationList -> VariableDeclaration
77+
if (ts.isVariableStatement(node)) {
78+
return ts.visitEachChild(node, visitVariableStatement, context);
79+
}
80+
81+
// There's no point in recursing into anything but variable statements, so return the node.
82+
return node;
83+
};
84+
85+
return ts.visitEachChild(sourceFile, visitToplevelNodes, context);
86+
};
87+
};
88+
}
89+
90+
function replaceImport(
91+
node: ts.VariableDeclaration,
92+
context: ts.TransformationContext,
93+
emitWarning: () => void,
94+
): ts.Node {
95+
// This ONLY matches the original source code format below:
96+
// loadChildren: () => import('IMPORT_STRING').then(m => m.EXPORT_NAME)
97+
// And expects that source code to be transformed by NGC (see comment for importFactory).
98+
// It will not match nor alter variations, for instance:
99+
// - not using arrow functions
100+
// - not using `m` as the module argument
101+
// - using `await` instead of `then`
102+
// - using a default export (https://github.com/angular/angular/issues/11402)
103+
// The only parts that can change are the ones in caps: IMPORT_STRING and EXPORT_NAME.
104+
105+
// Exit early if the structure is not what we expect.
106+
107+
// ɵ0 = something
108+
const name = node.name;
109+
if (!(
110+
ts.isIdentifier(name)
111+
&& /ɵ\d+/.test(name.text)
112+
)) {
113+
return node;
114+
}
115+
116+
const initializer = node.initializer;
117+
if (initializer === undefined) {
118+
return node;
119+
}
120+
121+
// ɵ0 = () => something
122+
if (!(
123+
ts.isArrowFunction(initializer)
124+
&& initializer.parameters.length === 0
125+
)) {
126+
return node;
127+
}
128+
129+
// ɵ0 = () => something.then(something)
130+
const topArrowFnBody = initializer.body;
131+
if (!ts.isCallExpression(topArrowFnBody)) {
132+
return node;
133+
}
134+
135+
const topArrowFnBodyExpr = topArrowFnBody.expression;
136+
if (!(
137+
ts.isPropertyAccessExpression(topArrowFnBodyExpr)
138+
&& ts.isIdentifier(topArrowFnBodyExpr.name)
139+
)) {
140+
return node;
141+
}
142+
if (topArrowFnBodyExpr.name.text != 'then') {
143+
return node;
144+
}
145+
146+
// ɵ0 = () => import('IMPORT_STRING').then(something)
147+
const importCall = topArrowFnBodyExpr.expression;
148+
if (!(
149+
ts.isCallExpression(importCall)
150+
&& importCall.expression.kind === ts.SyntaxKind.ImportKeyword
151+
&& importCall.arguments.length === 1
152+
&& ts.isStringLiteral(importCall.arguments[0])
153+
)) {
154+
return node;
155+
}
156+
157+
// ɵ0 = () => import('IMPORT_STRING').then(m => m.EXPORT_NAME)
158+
if (!(
159+
topArrowFnBody.arguments.length === 1
160+
&& ts.isArrowFunction(topArrowFnBody.arguments[0])
161+
)) {
162+
// Now that we know it's both `ɵ0` (generated by NGC) and a `import()`, start emitting a warning
163+
// if the structure isn't as expected to help users identify unusable syntax.
164+
emitWarning();
165+
166+
return node;
167+
}
168+
169+
const thenArrowFn = topArrowFnBody.arguments[0] as ts.ArrowFunction;
170+
if (!(
171+
thenArrowFn.parameters.length === 1
172+
&& ts.isPropertyAccessExpression(thenArrowFn.body)
173+
&& ts.isIdentifier(thenArrowFn.body.name)
174+
)) {
175+
emitWarning();
176+
177+
return node;
178+
}
179+
180+
// At this point we know what are the nodes we need to replace.
181+
const importStringLit = importCall.arguments[0] as ts.StringLiteral;
182+
const exportNameId = thenArrowFn.body.name;
183+
184+
// The easiest way to alter them is with a simple visitor.
185+
const replacementVisitor: ts.Visitor = (node: ts.Node) => {
186+
if (node === importStringLit) {
187+
// Transform the import string.
188+
return ts.createStringLiteral(importStringLit.text + '.ngfactory');
189+
} else if (node === exportNameId) {
190+
// Transform the export name.
191+
return ts.createIdentifier(exportNameId.text + 'NgFactory');
192+
}
193+
194+
return ts.visitEachChild(node, replacementVisitor, context);
195+
};
196+
197+
return ts.visitEachChild(node, replacementVisitor, context);
198+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { tags } from '@angular-devkit/core';
9+
import { transformTypescript } from './ast_helpers';
10+
import { importFactory } from './import_factory';
11+
12+
describe('@ngtools/webpack transformers', () => {
13+
describe('import_factory', () => {
14+
it('should support arrow functions', () => {
15+
const input = tags.stripIndent`
16+
const ɵ0 = () => import('./lazy/lazy.module').then(m => m.LazyModule);
17+
const routes = [{
18+
path: 'lazy',
19+
loadChildren: ɵ0
20+
}];
21+
`;
22+
const output = tags.stripIndent`
23+
const ɵ0 = () => import("./lazy/lazy.module.ngfactory").then(m => m.LazyModuleNgFactory);
24+
const routes = [{
25+
path: 'lazy',
26+
loadChildren: ɵ0
27+
}];
28+
`;
29+
30+
let warningCalled = false;
31+
const transformer = importFactory(() => warningCalled = true);
32+
const result = transformTypescript(input, [transformer]);
33+
34+
expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`);
35+
expect(warningCalled).toBeFalsy();
36+
});
37+
38+
it('should not transform if the format is different than expected', () => {
39+
const input = tags.stripIndent`
40+
const ɵ0 = () => import('./lazy/lazy.module').then(function (m) { return m.LazyModule; });
41+
const routes = [{
42+
path: 'lazy',
43+
loadChildren: ɵ0
44+
}];
45+
`;
46+
47+
let warningCalled = false;
48+
const transformer = importFactory(() => warningCalled = true);
49+
const result = transformTypescript(input, [transformer]);
50+
51+
expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${input}`);
52+
expect(warningCalled).toBeTruthy();
53+
});
54+
});
55+
});

packages/ngtools/webpack/src/transformers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export * from './register_locale_data';
1818
export * from './replace_resources';
1919
export * from './remove_decorators';
2020
export * from './find_resources';
21+
export * from './import_factory';

0 commit comments

Comments
 (0)