Alex Rickabaugh c1392ce618 feat(ivy): produce and consume ES2015 re-exports for NgModule re-exports (#28852)
In certain configurations (such as the g3 repository) which have lots of
small compilation units as well as strict dependency checking on generated
code, ngtsc's default strategy of directly importing directives/pipes into
components will not work. To handle these cases, an additional mode is
introduced, and is enabled when using the FileToModuleHost provided by such
compilation environments.

In this mode, when ngtsc encounters an NgModule which re-exports another
from a different file, it will re-export all the directives it contains at
the ES2015 level. The exports will have a predictable name based on the
FileToModuleHost. For example, if the host says that a directive Foo is
from the 'root/external/foo' module, ngtsc will add:

```
export {Foo as ɵng$root$external$foo$$Foo} from 'root/external/foo';
```

Consumers of the re-exported directive will then import it via this path
instead of directly from root/external/foo, preserving strict dependency
semantics.

PR Close #28852
2019-02-22 12:15:58 -08:00

286 lines
10 KiB
TypeScript

/**
* @license
* Copyright Google Inc. 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 {ExternalExpr, ExternalReference} from '@angular/compiler';
import * as ts from 'typescript';
import {AliasGenerator, FileToModuleHost, Reference} from '../../imports';
import {TypeScriptReflectionHost} from '../../reflection';
import {makeProgram} from '../../testing/in_memory_typescript';
import {ExportScope} from '../src/api';
import {MetadataDtsModuleScopeResolver} from '../src/dependency';
const MODULE_FROM_NODE_MODULES_PATH = /.*node_modules\/(\w+)\/index\.d\.ts$/;
const testHost: FileToModuleHost = {
fileNameToModuleName: function(imported: string): string {
const res = MODULE_FROM_NODE_MODULES_PATH.exec(imported) !;
return 'root/' + res[1];
}
};
/**
* Simple metadata types are added to the top of each testing file, for convenience.
*/
const PROLOG = `
export declare type ModuleMeta<A, B, C, D> = never;
export declare type ComponentMeta<A, B, C, D, E, F> = never;
export declare type DirectiveMeta<A, B, C, D, E, F> = never;
export declare type PipeMeta<A, B> = never;
`;
/**
* Construct the testing environment with a given set of absolute modules and their contents.
*
* This returns both the `MetadataDtsModuleScopeResolver` and a `refs` object which can be
* destructured to retrieve references to specific declared classes.
*/
function makeTestEnv(
modules: {[module: string]: string}, aliasGenerator: AliasGenerator | null = null): {
refs: {[name: string]: Reference<ts.ClassDeclaration>},
resolver: MetadataDtsModuleScopeResolver,
} {
// Map the modules object to an array of files for `makeProgram`.
const files = Object.keys(modules).map(moduleName => {
return {
name: `node_modules/${moduleName}/index.d.ts`,
contents: PROLOG + (modules as any)[moduleName],
};
});
const {program} = makeProgram(files);
const checker = program.getTypeChecker();
const resolver = new MetadataDtsModuleScopeResolver(
checker, new TypeScriptReflectionHost(checker), aliasGenerator);
// Resolver for the refs object.
const get = (target: {}, name: string): Reference<ts.ClassDeclaration> => {
for (const sf of program.getSourceFiles()) {
const symbol = checker.getSymbolAtLocation(sf) !;
const exportedSymbol = symbol.exports !.get(name as ts.__String);
if (exportedSymbol !== undefined) {
const decl = exportedSymbol.valueDeclaration as ts.ClassDeclaration;
const specifier = MODULE_FROM_NODE_MODULES_PATH.exec(sf.fileName) ![1];
return new Reference(decl, {specifier, resolutionContext: sf.fileName});
}
}
throw new Error('Class not found: ' + name);
};
return {
resolver,
refs: new Proxy({}, {get}),
};
}
describe('MetadataDtsModuleScopeResolver', () => {
it('should produce an accurate scope for a basic NgModule', () => {
const {resolver, refs} = makeTestEnv({
'test': `
export declare class Dir {
static ngDirectiveDef: DirectiveMeta<Dir, '[dir]', ['exportAs'], {'input': 'input2'},
{'output': 'output2'}, ['query']>;
}
export declare class Module {
static ngModuleDef: ModuleMeta<Module, [typeof Dir], never, [typeof Dir]>;
}
`
});
const {Dir, Module} = refs;
const scope = resolver.resolve(Module) !;
expect(scopeToRefs(scope)).toEqual([Dir]);
});
it('should produce an accurate scope when a module is exported', () => {
const {resolver, refs} = makeTestEnv({
'test': `
export declare class Dir {
static ngDirectiveDef: DirectiveMeta<Dir, '[dir]', never, never, never, never>;
}
export declare class ModuleA {
static ngModuleDef: ModuleMeta<ModuleA, [typeof Dir], never, [typeof Dir]>;
}
export declare class ModuleB {
static ngModuleDef: ModuleMeta<ModuleB, never, never, [typeof ModuleA]>;
}
`
});
const {Dir, ModuleB} = refs;
const scope = resolver.resolve(ModuleB) !;
expect(scopeToRefs(scope)).toEqual([Dir]);
});
it('should resolve correctly across modules', () => {
const {resolver, refs} = makeTestEnv({
'declaration': `
export declare class Dir {
static ngDirectiveDef: DirectiveMeta<Dir, '[dir]', never, never, never, never>;
}
export declare class ModuleA {
static ngModuleDef: ModuleMeta<ModuleA, [typeof Dir], never, [typeof Dir]>;
}
`,
'exported': `
import * as d from 'declaration';
export declare class ModuleB {
static ngModuleDef: ModuleMeta<ModuleB, never, never, [typeof d.ModuleA]>;
}
`
});
const {Dir, ModuleB} = refs;
const scope = resolver.resolve(ModuleB) !;
expect(scopeToRefs(scope)).toEqual([Dir]);
// Explicitly verify that the directive has the correct owning module.
expect(scope.exported.directives[0].ref.ownedByModuleGuess).toBe('declaration');
});
it('should write correct aliases for deep dependencies', () => {
const {resolver, refs} = makeTestEnv(
{
'deep': `
export declare class DeepDir {
static ngDirectiveDef: DirectiveMeta<DeepDir, '[deep]', never, never, never, never>;
}
export declare class DeepModule {
static ngModuleDef: ModuleMeta<DeepModule, [typeof DeepDir], never, [typeof DeepDir]>;
}
`,
'middle': `
import * as deep from 'deep';
export declare class MiddleDir {
static ngDirectiveDef: DirectiveMeta<MiddleDir, '[middle]', never, never, never, never>;
}
export declare class MiddleModule {
static ngModuleDef: ModuleMeta<MiddleModule, [typeof MiddleDir], never, [typeof MiddleDir, typeof deep.DeepModule]>;
}
`,
'shallow': `
import * as middle from 'middle';
export declare class ShallowDir {
static ngDirectiveDef: DirectiveMeta<ShallowDir, '[middle]', never, never, never, never>;
}
export declare class ShallowModule {
static ngModuleDef: ModuleMeta<ShallowModule, [typeof ShallowDir], never, [typeof ShallowDir, typeof middle.MiddleModule]>;
}
`,
},
new AliasGenerator(testHost));
const {ShallowModule} = refs;
const scope = resolver.resolve(ShallowModule) !;
const [DeepDir, MiddleDir, ShallowDir] = scopeToRefs(scope);
expect(getAlias(DeepDir)).toEqual({
moduleName: 'root/shallow',
name: 'ɵng$root$deep$$DeepDir',
});
expect(getAlias(MiddleDir)).toEqual({
moduleName: 'root/shallow',
name: 'ɵng$root$middle$$MiddleDir',
});
expect(getAlias(ShallowDir)).toBeNull();
});
it('should write correct aliases for bare directives in exports', () => {
const {resolver, refs} = makeTestEnv(
{
'deep': `
export declare class DeepDir {
static ngDirectiveDef: DirectiveMeta<DeepDir, '[deep]', never, never, never, never>;
}
export declare class DeepModule {
static ngModuleDef: ModuleMeta<DeepModule, [typeof DeepDir], never, [typeof DeepDir]>;
}
`,
'middle': `
import * as deep from 'deep';
export declare class MiddleDir {
static ngDirectiveDef: DirectiveMeta<MiddleDir, '[middle]', never, never, never, never>;
}
export declare class MiddleModule {
static ngModuleDef: ModuleMeta<MiddleModule, [typeof MiddleDir], [typeof deep.DeepModule], [typeof MiddleDir, typeof deep.DeepDir]>;
}
`,
'shallow': `
import * as middle from 'middle';
export declare class ShallowDir {
static ngDirectiveDef: DirectiveMeta<ShallowDir, '[middle]', never, never, never, never>;
}
export declare class ShallowModule {
static ngModuleDef: ModuleMeta<ShallowModule, [typeof ShallowDir], never, [typeof ShallowDir, typeof middle.MiddleModule]>;
}
`,
},
new AliasGenerator(testHost));
const {ShallowModule} = refs;
const scope = resolver.resolve(ShallowModule) !;
const [DeepDir, MiddleDir, ShallowDir] = scopeToRefs(scope);
expect(getAlias(DeepDir)).toEqual({
moduleName: 'root/shallow',
name: 'ɵng$root$deep$$DeepDir',
});
expect(getAlias(MiddleDir)).toEqual({
moduleName: 'root/shallow',
name: 'ɵng$root$middle$$MiddleDir',
});
expect(getAlias(ShallowDir)).toBeNull();
});
it('should not use an alias if a directive is declared in the same file as the re-exporting module',
() => {
const {resolver, refs} = makeTestEnv(
{
'module': `
export declare class DeepDir {
static ngDirectiveDef: DirectiveMeta<DeepDir, '[deep]', never, never, never, never>;
}
export declare class DeepModule {
static ngModuleDef: ModuleMeta<DeepModule, [typeof DeepDir], never, [typeof DeepDir]>;
}
export declare class DeepExportModule {
static ngModuleDef: ModuleMeta<DeepExportModule, never, never, [typeof DeepModule]>;
}
`,
},
new AliasGenerator(testHost));
const {DeepExportModule} = refs;
const scope = resolver.resolve(DeepExportModule) !;
const [DeepDir] = scopeToRefs(scope);
expect(getAlias(DeepDir)).toBeNull();
});
});
function scopeToRefs(scope: ExportScope): Reference<ts.ClassDeclaration>[] {
const directives = scope.exported.directives.map(dir => dir.ref);
const pipes = scope.exported.pipes.map(pipe => pipe.ref as Reference<ts.ClassDeclaration>);
return [...directives, ...pipes].sort((a, b) => a.debugName !.localeCompare(b.debugName !));
}
function getAlias(ref: Reference<ts.ClassDeclaration>): ExternalReference|null {
if (ref.alias === null) {
return null;
} else {
return (ref.alias as ExternalExpr).value;
}
}