feat(ivy): implement listing lazy routes for specific entry point in `ngtsc` (#28542)

Related: angular/angular-cli#13532

Jira issue: FW-860

PR Close #28542
This commit is contained in:
George Kalpakas 2019-02-05 16:47:35 +02:00 committed by Miško Hevery
parent f358188ec1
commit e6c51b3e06
4 changed files with 561 additions and 86 deletions

View File

@ -192,9 +192,7 @@ export class NgtscProgram implements api.Program {
listLazyRoutes(entryRoute?: string|undefined): api.LazyRoute[] { listLazyRoutes(entryRoute?: string|undefined): api.LazyRoute[] {
this.ensureAnalyzed(); this.ensureAnalyzed();
// Listing specific routes is unsupported for now, so we erroneously return return this.routeAnalyzer !.listLazyRoutes(entryRoute);
// all lazy routes instead (which should be okay for the CLI's usage).
return this.routeAnalyzer !.listLazyRoutes();
} }
getLibrarySummaries(): Map<string, api.LibrarySummary> { getLibrarySummaries(): Map<string, api.LibrarySummary> {

View File

@ -11,7 +11,7 @@ import * as ts from 'typescript';
import {ModuleResolver} from '../../imports'; import {ModuleResolver} from '../../imports';
import {PartialEvaluator} from '../../partial_evaluator'; import {PartialEvaluator} from '../../partial_evaluator';
import {scanForRouteEntryPoints} from './lazy'; import {scanForCandidateTransitiveModules, scanForRouteEntryPoints} from './lazy';
import {RouterEntryPointManager, entryPointKeyFor} from './route'; import {RouterEntryPointManager, entryPointKeyFor} from './route';
export interface NgModuleRawRouteData { export interface NgModuleRawRouteData {
@ -48,18 +48,53 @@ export class NgModuleRouteAnalyzer {
}); });
} }
listLazyRoutes(): LazyRoute[] { listLazyRoutes(entryModuleKey?: string|undefined): LazyRoute[] {
if ((entryModuleKey !== undefined) && !this.modules.has(entryModuleKey)) {
throw new Error(`Failed to list lazy routes: Unknown module '${entryModuleKey}'.`);
}
const routes: LazyRoute[] = []; const routes: LazyRoute[] = [];
for (const key of Array.from(this.modules.keys())) { const scannedModuleKeys = new Set<string>();
const pendingModuleKeys = entryModuleKey ? [entryModuleKey] : Array.from(this.modules.keys());
// When listing lazy routes for a specific entry module, we need to recursively extract
// "transitive" routes from imported/exported modules. This is not necessary when listing all
// lazy routes, because all analyzed modules will be scanned anyway.
const scanRecursively = entryModuleKey !== undefined;
while (pendingModuleKeys.length > 0) {
const key = pendingModuleKeys.pop() !;
if (scannedModuleKeys.has(key)) {
continue;
} else {
scannedModuleKeys.add(key);
}
const data = this.modules.get(key) !; const data = this.modules.get(key) !;
const entryPoints = scanForRouteEntryPoints( const entryPoints = scanForRouteEntryPoints(
data.sourceFile, data.moduleName, data, this.entryPointManager, this.evaluator); data.sourceFile, data.moduleName, data, this.entryPointManager, this.evaluator);
routes.push(...entryPoints.map(entryPoint => ({ routes.push(...entryPoints.map(entryPoint => ({
route: entryPoint.loadChildren, route: entryPoint.loadChildren,
module: entryPoint.from, module: entryPoint.from,
referencedModule: entryPoint.resolvedTo, referencedModule: entryPoint.resolvedTo,
}))); })));
if (scanRecursively) {
pendingModuleKeys.push(
...[
// Scan the retrieved lazy route entry points.
...entryPoints.map(
({resolvedTo}) => entryPointKeyFor(resolvedTo.filePath, resolvedTo.moduleName)),
// Scan the current module's imported modules.
...scanForCandidateTransitiveModules(data.imports, this.evaluator),
// Scan the current module's exported modules.
...scanForCandidateTransitiveModules(data.exports, this.evaluator),
].filter(key => this.modules.has(key)));
} }
}
return routes; return routes;
} }
} }

View File

@ -12,7 +12,7 @@ import {AbsoluteReference, NodeReference, Reference} from '../../imports';
import {ForeignFunctionResolver, PartialEvaluator, ResolvedValue} from '../../partial_evaluator'; import {ForeignFunctionResolver, PartialEvaluator, ResolvedValue} from '../../partial_evaluator';
import {NgModuleRawRouteData} from './analyzer'; import {NgModuleRawRouteData} from './analyzer';
import {RouterEntryPoint, RouterEntryPointManager} from './route'; import {RouterEntryPoint, RouterEntryPointManager, entryPointKeyFor} from './route';
const ROUTES_MARKER = '__ngRoutesMarker__'; const ROUTES_MARKER = '__ngRoutesMarker__';
@ -22,6 +22,35 @@ export interface LazyRouteEntry {
resolvedTo: RouterEntryPoint; resolvedTo: RouterEntryPoint;
} }
export function scanForCandidateTransitiveModules(
expr: ts.Expression | null, evaluator: PartialEvaluator): string[] {
if (expr === null) {
return [];
}
const candidateModuleKeys: string[] = [];
const entries = evaluator.evaluate(expr);
function recursivelyAddModules(entry: ResolvedValue) {
if (Array.isArray(entry)) {
for (const e of entry) {
recursivelyAddModules(e);
}
} else if (entry instanceof Map) {
if (entry.has('ngModule')) {
recursivelyAddModules(entry.get('ngModule') !);
}
} else if ((entry instanceof Reference) && hasIdentifier(entry.node)) {
const filePath = entry.node.getSourceFile().fileName;
const moduleName = entry.node.name.text;
candidateModuleKeys.push(entryPointKeyFor(filePath, moduleName));
}
}
recursivelyAddModules(entries);
return candidateModuleKeys;
}
export function scanForRouteEntryPoints( export function scanForRouteEntryPoints(
ngModule: ts.SourceFile, moduleName: string, data: NgModuleRawRouteData, ngModule: ts.SourceFile, moduleName: string, data: NgModuleRawRouteData,
entryPointManager: RouterEntryPointManager, evaluator: PartialEvaluator): LazyRouteEntry[] { entryPointManager: RouterEntryPointManager, evaluator: PartialEvaluator): LazyRouteEntry[] {
@ -152,6 +181,11 @@ const routerModuleFFR: ForeignFunctionResolver =
]); ]);
}; };
function hasIdentifier(node: ts.Node): node is ts.Node&{name: ts.Identifier} {
const node_ = node as ts.NamedDeclaration;
return (node_.name !== undefined) && ts.isIdentifier(node_.name);
}
function isMethodNodeReference( function isMethodNodeReference(
ref: Reference<ts.FunctionDeclaration|ts.MethodDeclaration|ts.FunctionExpression>): ref: Reference<ts.FunctionDeclaration|ts.MethodDeclaration|ts.FunctionExpression>):
ref is NodeReference<ts.MethodDeclaration> { ref is NodeReference<ts.MethodDeclaration> {

View File

@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {LazyRoute} from '@angular/compiler-cli/src/ngtsc/routing';
import * as path from 'path';
import * as ts from 'typescript'; import * as ts from 'typescript';
import {NgtscTestEnvironment} from './env'; import {NgtscTestEnvironment} from './env';
@ -1992,28 +1994,27 @@ describe('ngtsc behavioral tests', () => {
}); });
}); });
it('should detect all lazy routes', () => { describe('listLazyRoutes()', () => {
// clang-format off
const lazyRouteMatching = (
route: string, fromModulePath: RegExp, fromModuleName: string, toModulePath: RegExp,
toModuleName: string) => {
return {
route,
module: jasmine.objectContaining({
name: fromModuleName,
filePath: jasmine.stringMatching(fromModulePath),
}),
referencedModule: jasmine.objectContaining({
name: toModuleName,
filePath: jasmine.stringMatching(toModulePath),
}),
} as unknown as LazyRoute;
};
// clang-format on
beforeEach(() => {
env.tsconfig(); env.tsconfig();
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forChild([
{path: '', loadChildren: './lazy#LazyModule'},
]),
],
})
export class TestModule {}
`);
env.write('lazy.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({})
export class LazyModule {}
`);
env.write('node_modules/@angular/router/index.d.ts', ` env.write('node_modules/@angular/router/index.d.ts', `
import {ModuleWithProviders} from '@angular/core'; import {ModuleWithProviders} from '@angular/core';
@ -2023,16 +2024,65 @@ describe('ngtsc behavioral tests', () => {
static forChild(arg1: any): ModuleWithProviders<RouterModule>; static forChild(arg1: any): ModuleWithProviders<RouterModule>;
} }
`); `);
});
describe('when called without arguments', () => {
it('should list all routes', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forRoot([
{path: '1', loadChildren: './lazy/lazy-1#Lazy1Module'},
{path: '2', loadChildren: './lazy/lazy-2#Lazy2Module'},
]),
],
})
export class TestModule {}
`);
env.write('lazy/lazy-1.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy1Module {}
`);
env.write('lazy/lazy-2.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forChild([
{path: '3', loadChildren: './lazy-3#Lazy3Module'},
]),
],
})
export class Lazy2Module {}
`);
env.write('lazy/lazy-3.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy3Module {}
`);
const routes = env.driveRoutes(); const routes = env.driveRoutes();
expect(routes.length).toBe(1); expect(routes).toEqual([
expect(routes[0].route).toEqual('./lazy#LazyModule'); lazyRouteMatching(
expect(routes[0].module.filePath.endsWith('/test.ts')).toBe(true); './lazy-3#Lazy3Module', /\/lazy\/lazy-2\.ts$/, 'Lazy2Module', /\/lazy\/lazy-3\.ts$/,
expect(routes[0].referencedModule.filePath.endsWith('/lazy.ts')).toBe(true); 'Lazy3Module'),
lazyRouteMatching(
'./lazy/lazy-1#Lazy1Module', /\/test\.ts$/, 'TestModule', /\/lazy\/lazy-1\.ts$/,
'Lazy1Module'),
lazyRouteMatching(
'./lazy/lazy-2#Lazy2Module', /\/test\.ts$/, 'TestModule', /\/lazy\/lazy-2\.ts$/,
'Lazy2Module'),
]);
}); });
it('should detect lazy routes in simple children routes', () => { it('should detect lazy routes in simple children routes', () => {
env.tsconfig();
env.write('test.ts', ` env.write('test.ts', `
import {NgModule} from '@angular/core'; import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router'; import {RouterModule} from '@angular/router';
@ -2062,21 +2112,379 @@ describe('ngtsc behavioral tests', () => {
@NgModule({}) @NgModule({})
export class LazyModule {} export class LazyModule {}
`); `);
env.write('node_modules/@angular/router/index.d.ts', `
import {ModuleWithProviders} from '@angular/core';
export declare var ROUTES;
export declare class RouterModule {
static forRoot(arg1: any, arg2: any): ModuleWithProviders<RouterModule>;
static forChild(arg1: any): ModuleWithProviders<RouterModule>;
}
`);
const routes = env.driveRoutes(); const routes = env.driveRoutes();
expect(routes.length).toBe(1); expect(routes).toEqual([
expect(routes[0].route).toEqual('./lazy#LazyModule'); lazyRouteMatching(
expect(routes[0].module.filePath.endsWith('/test.ts')).toBe(true); './lazy#LazyModule', /\/test\.ts$/, 'TestModule', /\/lazy\.ts$/, 'LazyModule'),
expect(routes[0].referencedModule.filePath.endsWith('/lazy.ts')).toBe(true); ]);
});
});
describe('when called with entry module', () => {
it('should throw if the entry module hasn\'t been analyzed', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forChild([
{path: '', loadChildren: './lazy#LazyModule'},
]),
],
})
export class TestModule {}
`);
const entryModule1 = path.join(env.basePath, 'test#TestModule');
const entryModule2 = path.join(env.basePath, 'not-test#TestModule');
const entryModule3 = path.join(env.basePath, 'test#NotTestModule');
expect(() => env.driveRoutes(entryModule1)).not.toThrow();
expect(() => env.driveRoutes(entryModule2))
.toThrowError(`Failed to list lazy routes: Unknown module '${entryModule2}'.`);
expect(() => env.driveRoutes(entryModule3))
.toThrowError(`Failed to list lazy routes: Unknown module '${entryModule3}'.`);
});
it('should list all transitive lazy routes', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {Test1Module as Test1ModuleRenamed} from './test-1';
import {Test2Module} from './test-2';
@NgModule({
exports: [
Test1ModuleRenamed,
],
imports: [
Test2Module,
RouterModule.forRoot([
{path: '', loadChildren: './lazy/lazy#LazyModule'},
]),
],
})
export class TestModule {}
`);
env.write('test-1.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forChild([
{path: 'one', loadChildren: './lazy-1/lazy-1#Lazy1Module'},
]),
],
})
export class Test1Module {}
`);
env.write('test-2.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
exports: [
RouterModule.forChild([
{path: 'two', loadChildren: './lazy-2/lazy-2#Lazy2Module'},
]),
],
})
export class Test2Module {}
`);
env.write('lazy/lazy.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class LazyModule {}
`);
env.write('lazy-1/lazy-1.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy1Module {}
`);
env.write('lazy-2/lazy-2.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy2Module {}
`);
const routes = env.driveRoutes(path.join(env.basePath, 'test#TestModule'));
expect(routes).toEqual([
lazyRouteMatching(
'./lazy/lazy#LazyModule', /\/test\.ts$/, 'TestModule', /\/lazy\/lazy\.ts$/,
'LazyModule'),
lazyRouteMatching(
'./lazy-1/lazy-1#Lazy1Module', /\/test-1\.ts$/, 'Test1Module',
/\/lazy-1\/lazy-1\.ts$/, 'Lazy1Module'),
lazyRouteMatching(
'./lazy-2/lazy-2#Lazy2Module', /\/test-2\.ts$/, 'Test2Module',
/\/lazy-2\/lazy-2\.ts$/, 'Lazy2Module'),
]);
});
it('should ignore exports that do not refer to an `NgModule`', () => {
env.write('test-1.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {Test2Component, Test2Module} from './test-2';
@NgModule({
exports: [
Test2Component,
Test2Module,
],
imports: [
RouterModule.forRoot([
{path: '', loadChildren: './lazy-1/lazy-1#Lazy1Module'},
]),
],
})
export class Test1Module {}
`);
env.write('test-2.ts', `
import {Component, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({
selector: 'test-2',
template: '',
})
export class Test2Component {}
@NgModule({
declarations: [
Test2Component,
],
exports: [
Test2Component,
RouterModule.forChild([
{path: 'two', loadChildren: './lazy-2/lazy-2#Lazy2Module'},
]),
],
})
export class Test2Module {}
`);
env.write('lazy-1/lazy-1.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy1Module {}
`);
env.write('lazy-2/lazy-2.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy2Module {}
`);
const routes = env.driveRoutes(path.join(env.basePath, 'test-1#Test1Module'));
expect(routes).toEqual([
lazyRouteMatching(
'./lazy-1/lazy-1#Lazy1Module', /\/test-1\.ts$/, 'Test1Module',
/\/lazy-1\/lazy-1\.ts$/, 'Lazy1Module'),
lazyRouteMatching(
'./lazy-2/lazy-2#Lazy2Module', /\/test-2\.ts$/, 'Test2Module',
/\/lazy-2\/lazy-2\.ts$/, 'Lazy2Module'),
]);
});
it('should support `ModuleWithProviders`', () => {
env.write('test.ts', `
import {ModuleWithProviders, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forChild([
{path: '', loadChildren: './lazy-2/lazy-2#Lazy2Module'},
]),
],
})
export class TestRoutingModule {
static forRoot(): ModuleWithProviders<TestRoutingModule> {
return {
ngModule: TestRoutingModule,
providers: [],
};
}
}
@NgModule({
imports: [
TestRoutingModule.forRoot(),
RouterModule.forRoot([
{path: '', loadChildren: './lazy-1/lazy-1#Lazy1Module'},
]),
],
})
export class TestModule {}
`);
env.write('lazy-1/lazy-1.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy1Module {}
`);
env.write('lazy-2/lazy-2.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy2Module {}
`);
const routes = env.driveRoutes(path.join(env.basePath, 'test#TestModule'));
expect(routes).toEqual([
lazyRouteMatching(
'./lazy-1/lazy-1#Lazy1Module', /\/test\.ts$/, 'TestModule', /\/lazy-1\/lazy-1\.ts$/,
'Lazy1Module'),
lazyRouteMatching(
'./lazy-2/lazy-2#Lazy2Module', /\/test\.ts$/, 'TestRoutingModule',
/\/lazy-2\/lazy-2\.ts$/, 'Lazy2Module'),
]);
});
it('should only process each module once', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forChild([
{path: '', loadChildren: './lazy/lazy#LazyModule'},
]),
],
})
export class SharedModule {}
@NgModule({
imports: [
SharedModule,
RouterModule.forRoot([
{path: '', loadChildren: './lazy/lazy#LazyModule'},
]),
],
})
export class TestModule {}
`);
env.write('lazy/lazy.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forChild([
{path: '', loadChildren: '../lazier/lazier#LazierModule'},
]),
],
})
export class LazyModule {}
`);
env.write('lazier/lazier.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class LazierModule {}
`);
const routes = env.driveRoutes(path.join(env.basePath, 'test#TestModule'));
// `LazyModule` is referenced in both `SharedModule` and `TestModule`,
// but it is only processed once (hence one `LazierModule` entry).
expect(routes).toEqual([
lazyRouteMatching(
'./lazy/lazy#LazyModule', /\/test\.ts$/, 'TestModule', /\/lazy\/lazy\.ts$/,
'LazyModule'),
lazyRouteMatching(
'./lazy/lazy#LazyModule', /\/test\.ts$/, 'SharedModule', /\/lazy\/lazy\.ts$/,
'LazyModule'),
lazyRouteMatching(
'../lazier/lazier#LazierModule', /\/lazy\/lazy\.ts$/, 'LazyModule',
/\/lazier\/lazier\.ts$/, 'LazierModule'),
]);
});
it('should ignore modules not (transitively) referenced by the entry module', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forRoot([
{path: '', loadChildren: './lazy/lazy#Lazy1Module'},
]),
],
})
export class Test1Module {}
@NgModule({
imports: [
RouterModule.forRoot([
{path: '', loadChildren: './lazy/lazy#Lazy2Module'},
]),
],
})
export class Test2Module {}
`);
env.write('lazy/lazy.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class Lazy1Module {}
@NgModule({})
export class Lazy2Module {}
`);
const routes = env.driveRoutes(path.join(env.basePath, 'test#Test1Module'));
expect(routes).toEqual([
lazyRouteMatching(
'./lazy/lazy#Lazy1Module', /\/test\.ts$/, 'Test1Module', /\/lazy\/lazy\.ts$/,
'Lazy1Module'),
]);
});
it('should ignore routes to unknown modules', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@NgModule({
imports: [
RouterModule.forRoot([
{path: '', loadChildren: './unknown/unknown#UnknownModule'},
{path: '', loadChildren: './lazy/lazy#LazyModule'},
]),
],
})
export class TestModule {}
`);
env.write('lazy/lazy.ts', `
import {NgModule} from '@angular/core';
@NgModule({})
export class LazyModule {}
`);
const routes = env.driveRoutes(path.join(env.basePath, 'test#TestModule'));
expect(routes).toEqual([
lazyRouteMatching(
'./lazy/lazy#LazyModule', /\/test\.ts$/, 'TestModule', /\/lazy\/lazy\.ts$/,
'LazyModule'),
]);
});
});
}); });
}); });