feat(ivy): selector side of ModuleWithProviders via type metadata (#24862)

Within an @NgModule it's common to include in the imports a call to
a ModuleWithProviders function, for example RouterModule.forRoot().
The old ngc compiler was able to handle this pattern because it had
global knowledge of metadata of not only the input compilation unit
but also all dependencies.

The ngtsc compiler for Ivy doesn't have this knowledge, so the
pattern of ModuleWithProviders functions is more difficult. ngtsc
must be able to determine which module is imported via the function
in order to expand the selector scope and properly tree-shake
directives and pipes.

This commit implements a solution to this problem, by adding a type
parameter to ModuleWithProviders through which the actual module
type can be passed between compilation units.

The provider side isn't a problem because the imports are always
copied directly to the ngInjectorDef.

PR Close #24862
This commit is contained in:
Alex Rickabaugh 2018-07-09 11:36:30 -07:00 committed by Victor Berchet
parent 1008bb6287
commit 60aeee7abf
14 changed files with 136 additions and 23 deletions

View File

@ -64,12 +64,16 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<NgModuleAnalys
} }
let imports: Reference[] = []; let imports: Reference[] = [];
if (ngModule.has('imports')) { if (ngModule.has('imports')) {
const importsMeta = staticallyResolve(ngModule.get('imports') !, this.checker); const importsMeta = staticallyResolve(
ngModule.get('imports') !, this.checker,
node => this._extractModuleFromModuleWithProvidersFn(node));
imports = resolveTypeList(importsMeta, 'imports'); imports = resolveTypeList(importsMeta, 'imports');
} }
let exports: Reference[] = []; let exports: Reference[] = [];
if (ngModule.has('exports')) { if (ngModule.has('exports')) {
const exportsMeta = staticallyResolve(ngModule.get('exports') !, this.checker); const exportsMeta = staticallyResolve(
ngModule.get('exports') !, this.checker,
node => this._extractModuleFromModuleWithProvidersFn(node));
exports = resolveTypeList(exportsMeta, 'exports'); exports = resolveTypeList(exportsMeta, 'exports');
} }
@ -125,6 +129,46 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<NgModuleAnalys
}, },
]; ];
} }
/**
* Given a `FunctionDeclaration` or `MethodDeclaration`, check if it is typed as a
* `ModuleWithProviders` and return an expression referencing the module if available.
*/
private _extractModuleFromModuleWithProvidersFn(node: ts.FunctionDeclaration|
ts.MethodDeclaration): ts.Expression|null {
const type = node.type;
// Examine the type of the function to see if it's a ModuleWithProviders reference.
if (type === undefined || !ts.isTypeReferenceNode(type) || !ts.isIdentifier(type.typeName)) {
return null;
}
// Look at the type itself to see where it comes from.
const id = this.reflector.getImportOfIdentifier(type.typeName);
// If it's not named ModuleWithProviders, bail.
if (id === null || id.name !== 'ModuleWithProviders') {
return null;
}
// If it's not from @angular/core, bail.
if (!this.isCore && id.from !== '@angular/core') {
return null;
}
// If there's no type parameter specified, bail.
if (type.typeArguments === undefined || type.typeArguments.length !== 1) {
return null;
}
const arg = type.typeArguments[0];
// If the argument isn't an Identifier, bail.
if (!ts.isTypeReferenceNode(arg) || !ts.isIdentifier(arg.typeName)) {
return null;
}
return arg.typeName;
}
} }
/** /**

View File

@ -107,6 +107,8 @@ export abstract class Reference {
* referenceable. * referenceable.
*/ */
export class NodeReference extends Reference { export class NodeReference extends Reference {
constructor(node: ts.Node, readonly moduleName: string|null) { super(node); }
toExpression(context: ts.SourceFile): null { return null; } toExpression(context: ts.SourceFile): null { return null; }
} }
@ -177,11 +179,22 @@ export class AbsoluteReference extends Reference {
* *
* @param node the expression to statically resolve if possible * @param node the expression to statically resolve if possible
* @param checker a `ts.TypeChecker` used to understand the expression * @param checker a `ts.TypeChecker` used to understand the expression
* @param foreignFunctionResolver a function which will be used whenever a "foreign function" is
* encountered. A foreign function is a function which has no body - usually the result of calling
* a function declared in another library's .d.ts file. In these cases, the foreignFunctionResolver
* will be called with the function's declaration, and can optionally return a `ts.Expression`
* (possibly extracted from the foreign function's type signature) which will be used as the result
* of the call.
* @returns a `ResolvedValue` representing the resolved value * @returns a `ResolvedValue` representing the resolved value
*/ */
export function staticallyResolve(node: ts.Expression, checker: ts.TypeChecker): ResolvedValue { export function staticallyResolve(
return new StaticInterpreter(checker).visit( node: ts.Expression, checker: ts.TypeChecker,
node, {absoluteModuleName: null, scope: new Map<ts.ParameterDeclaration, ResolvedValue>()}); foreignFunctionResolver?: (node: ts.FunctionDeclaration | ts.MethodDeclaration) =>
ts.Expression | null): ResolvedValue {
return new StaticInterpreter(checker).visit(node, {
absoluteModuleName: null,
scope: new Map<ts.ParameterDeclaration, ResolvedValue>(), foreignFunctionResolver,
});
} }
interface BinaryOperatorDef { interface BinaryOperatorDef {
@ -226,6 +239,7 @@ const UNARY_OPERATORS = new Map<ts.SyntaxKind, (a: any) => any>([
interface Context { interface Context {
absoluteModuleName: string|null; absoluteModuleName: string|null;
scope: Scope; scope: Scope;
foreignFunctionResolver?(node: ts.FunctionDeclaration|ts.MethodDeclaration): ts.Expression|null;
} }
class StaticInterpreter { class StaticInterpreter {
@ -472,6 +486,10 @@ class StaticInterpreter {
} else if (lhs instanceof Reference) { } else if (lhs instanceof Reference) {
const ref = lhs.node; const ref = lhs.node;
if (ts.isClassDeclaration(ref)) { if (ts.isClassDeclaration(ref)) {
let absoluteModuleName = context.absoluteModuleName;
if (lhs instanceof NodeReference || lhs instanceof AbsoluteReference) {
absoluteModuleName = lhs.moduleName || absoluteModuleName;
}
let value: ResolvedValue = undefined; let value: ResolvedValue = undefined;
const member = const member =
ref.members.filter(member => isStatic(member)) ref.members.filter(member => isStatic(member))
@ -482,7 +500,7 @@ class StaticInterpreter {
if (ts.isPropertyDeclaration(member) && member.initializer !== undefined) { if (ts.isPropertyDeclaration(member) && member.initializer !== undefined) {
value = this.visitExpression(member.initializer, context); value = this.visitExpression(member.initializer, context);
} else if (ts.isMethodDeclaration(member)) { } else if (ts.isMethodDeclaration(member)) {
value = new NodeReference(member); value = new NodeReference(member, absoluteModuleName);
} }
} }
return value; return value;
@ -495,13 +513,35 @@ class StaticInterpreter {
const lhs = this.visitExpression(node.expression, context); const lhs = this.visitExpression(node.expression, context);
if (!(lhs instanceof Reference)) { if (!(lhs instanceof Reference)) {
throw new Error(`attempting to call something that is not a function: ${lhs}`); throw new Error(`attempting to call something that is not a function: ${lhs}`);
} else if (!isFunctionOrMethodDeclaration(lhs.node) || !lhs.node.body) { } else if (!isFunctionOrMethodDeclaration(lhs.node)) {
throw new Error( throw new Error(
`calling something that is not a function declaration? ${ts.SyntaxKind[lhs.node.kind]}`); `calling something that is not a function declaration? ${ts.SyntaxKind[lhs.node.kind]}`);
} }
const fn = lhs.node; const fn = lhs.node;
const body = fn.body as ts.Block;
// If the function is foreign (declared through a d.ts file), attempt to resolve it with the
// foreignFunctionResolver, if one is specified.
if (fn.body === undefined) {
let expr: ts.Expression|null = null;
if (context.foreignFunctionResolver) {
expr = context.foreignFunctionResolver(fn);
}
if (expr === null) {
throw new Error(`could not resolve foreign function declaration`);
}
// If the function is declared in a different file, resolve the foreign function expression
// using the absolute module name of that file (if any).
let absoluteModuleName: string|null = context.absoluteModuleName;
if (lhs instanceof NodeReference || lhs instanceof AbsoluteReference) {
absoluteModuleName = lhs.moduleName || absoluteModuleName;
}
return this.visitExpression(expr, {...context, absoluteModuleName});
}
const body = fn.body;
if (body.statements.length !== 1 || !ts.isReturnStatement(body.statements[0])) { if (body.statements.length !== 1 || !ts.isReturnStatement(body.statements[0])) {
throw new Error('Function body must have a single return statement only.'); throw new Error('Function body must have a single return statement only.');
} }

View File

@ -26,3 +26,4 @@ export const Inject = callableParamDecorator();
export const Self = callableParamDecorator(); export const Self = callableParamDecorator();
export const SkipSelf = callableParamDecorator(); export const SkipSelf = callableParamDecorator();
export const Optional = callableParamDecorator(); export const Optional = callableParamDecorator();
export type ModuleWithProviders<T> = any;

View File

@ -344,4 +344,30 @@ describe('ngtsc behavioral tests', () => {
const dtsContents = getContents('test.d.ts'); const dtsContents = getContents('test.d.ts');
expect(dtsContents).toContain('i0.ɵNgModuleDef<TestModule, [TestPipe,TestCmp], [], []>'); expect(dtsContents).toContain('i0.ɵNgModuleDef<TestModule, [TestPipe,TestCmp], [], []>');
}); });
it('should unwrap a ModuleWithProviders functoin if a generic type is provided for it', () => {
writeConfig();
write(`test.ts`, `
import {NgModule} from '@angular/core';
import {RouterModule} from 'router';
@NgModule({imports: [RouterModule.forRoot()]})
export class TestModule {}
`);
write('node_modules/router/index.d.ts', `
import {ModuleWithProviders} from '@angular/core';
declare class RouterModule {
static forRoot(): ModuleWithProviders<RouterModule>;
}
`);
const exitCode = main(['-p', basePath], errorSpy);
expect(errorSpy).not.toHaveBeenCalled();
expect(exitCode).toBe(0);
const dtsContents = getContents('test.d.ts');
expect(dtsContents).toContain(`import * as i1 from 'router';`);
expect(dtsContents).toContain('i0.ɵNgModuleDef<TestModule, [], [i1.RouterModule], []>');
});
}); });

View File

@ -73,10 +73,11 @@ export interface NgModuleDef<T, Declarations, Imports, Exports> {
/** /**
* A wrapper around an NgModule that associates it with the providers. * A wrapper around an NgModule that associates it with the providers.
* *
* * @param T the module type. In Ivy applications, this must be explicitly
* provided.
*/ */
export interface ModuleWithProviders { export interface ModuleWithProviders<T = any> {
ngModule: Type<any>; ngModule: Type<T>;
providers?: Provider[]; providers?: Provider[];
} }

View File

@ -38,7 +38,7 @@ export class FormsModule {
export class ReactiveFormsModule { export class ReactiveFormsModule {
static withConfig(opts: { static withConfig(opts: {
/** @deprecated as of v6 */ warnOnNgModelWithFormControl: 'never' | 'once' | 'always' /** @deprecated as of v6 */ warnOnNgModelWithFormControl: 'never' | 'once' | 'always'
}): ModuleWithProviders { }): ModuleWithProviders<ReactiveFormsModule> {
return { return {
ngModule: ReactiveFormsModule, ngModule: ReactiveFormsModule,
providers: [{ providers: [{

View File

@ -112,7 +112,7 @@ export class BrowserModule {
* *
* @experimental * @experimental
*/ */
static withServerTransition(params: {appId: string}): ModuleWithProviders { static withServerTransition(params: {appId: string}): ModuleWithProviders<BrowserModule> {
return { return {
ngModule: BrowserModule, ngModule: BrowserModule,
providers: [ providers: [

View File

@ -155,7 +155,7 @@ export class RouterModule {
* * `paramsInheritanceStrategy` defines how the router merges params, data and resolved data * * `paramsInheritanceStrategy` defines how the router merges params, data and resolved data
* from parent to child routes. * from parent to child routes.
*/ */
static forRoot(routes: Routes, config?: ExtraOptions): ModuleWithProviders { static forRoot(routes: Routes, config?: ExtraOptions): ModuleWithProviders<RouterModule> {
return { return {
ngModule: RouterModule, ngModule: RouterModule,
providers: [ providers: [
@ -193,7 +193,7 @@ export class RouterModule {
/** /**
* Creates a module with all the router directives and a provider registering routes. * Creates a module with all the router directives and a provider registering routes.
*/ */
static forChild(routes: Routes): ModuleWithProviders { static forChild(routes: Routes): ModuleWithProviders<RouterModule> {
return {ngModule: RouterModule, providers: [provideRoutes(routes)]}; return {ngModule: RouterModule, providers: [provideRoutes(routes)]};
} }
} }

View File

@ -180,7 +180,8 @@ export function setupTestingRouter(
] ]
}) })
export class RouterTestingModule { export class RouterTestingModule {
static withRoutes(routes: Routes, config?: ExtraOptions): ModuleWithProviders { static withRoutes(routes: Routes, config?: ExtraOptions):
ModuleWithProviders<RouterTestingModule> {
return { return {
ngModule: RouterTestingModule, ngModule: RouterTestingModule,
providers: [ providers: [

View File

@ -510,8 +510,8 @@ export declare class ModuleWithComponentFactories<T> {
constructor(ngModuleFactory: NgModuleFactory<T>, componentFactories: ComponentFactory<any>[]); constructor(ngModuleFactory: NgModuleFactory<T>, componentFactories: ComponentFactory<any>[]);
} }
export interface ModuleWithProviders { export interface ModuleWithProviders<T = any> {
ngModule: Type<any>; ngModule: Type<T>;
providers?: Provider[]; providers?: Provider[];
} }

View File

@ -458,7 +458,7 @@ export declare class RadioControlValueAccessor implements ControlValueAccessor,
export declare class ReactiveFormsModule { export declare class ReactiveFormsModule {
static withConfig(opts: { warnOnNgModelWithFormControl: 'never' | 'once' | 'always'; static withConfig(opts: { warnOnNgModelWithFormControl: 'never' | 'once' | 'always';
}): ModuleWithProviders; }): ModuleWithProviders<ReactiveFormsModule>;
} }
export declare class RequiredValidator implements Validator { export declare class RequiredValidator implements Validator {

View File

@ -2,7 +2,7 @@ export declare class BrowserModule {
constructor(parentModule: BrowserModule | null); constructor(parentModule: BrowserModule | null);
/** @experimental */ static withServerTransition(params: { /** @experimental */ static withServerTransition(params: {
appId: string; appId: string;
}): ModuleWithProviders; }): ModuleWithProviders<BrowserModule>;
} }
/** @experimental */ /** @experimental */

View File

@ -414,8 +414,8 @@ export declare class RouterLinkWithHref implements OnChanges, OnDestroy {
export declare class RouterModule { export declare class RouterModule {
constructor(guard: any, router: Router); constructor(guard: any, router: Router);
static forChild(routes: Routes): ModuleWithProviders; static forChild(routes: Routes): ModuleWithProviders<RouterModule>;
static forRoot(routes: Routes, config?: ExtraOptions): ModuleWithProviders; static forRoot(routes: Routes, config?: ExtraOptions): ModuleWithProviders<RouterModule>;
} }
export declare class RouterOutlet implements OnDestroy, OnInit { export declare class RouterOutlet implements OnDestroy, OnInit {

View File

@ -1,5 +1,5 @@
export declare class RouterTestingModule { export declare class RouterTestingModule {
static withRoutes(routes: Routes, config?: ExtraOptions): ModuleWithProviders; static withRoutes(routes: Routes, config?: ExtraOptions): ModuleWithProviders<RouterTestingModule>;
} }
export declare function setupTestingRouter(urlSerializer: UrlSerializer, contexts: ChildrenOutletContexts, location: Location, loader: NgModuleFactoryLoader, compiler: Compiler, injector: Injector, routes: Route[][], opts?: ExtraOptions, urlHandlingStrategy?: UrlHandlingStrategy): Router; export declare function setupTestingRouter(urlSerializer: UrlSerializer, contexts: ChildrenOutletContexts, location: Location, loader: NgModuleFactoryLoader, compiler: Compiler, injector: Injector, routes: Route[][], opts?: ExtraOptions, urlHandlingStrategy?: UrlHandlingStrategy): Router;