Skip to content

Commit 35450c7

Browse files
JoostKmhevery
authored andcommitted
fix(compiler-cli): prefer non-aliased exports in reference emitters (#41866)
This commit changes the reference emitters in the Ivy compiler to prefer non-aliased exports if they exist. This avoids selecting "private exports" that may not be stable, e.g. the reexports that have been added by the View Engine compiler. Such reexports are not stable and are therefore not suitable to be emitted into partial compilations, as the output of partial compilations should only reference stable symbols from upstream libraries. An alternative solution has been considered where ViewEngine-generated exports would gain a certain prefix, such that the Ivy compiler could just exclude those exports (see #41443). However, that solution would be insufficient in case a library is built using partial compilation and while depending itself on a VE-compiled library from earlier versions of Angular, where the magic prefix would be missing. For such libraries, ngcc would have generated reexports using the declared name if not already present so this change does result in choosing the correct export. Because ngcc always generates reexports using the declared name even if an aliased export is present, this change causes those ngcc-generated exports to be chosen in downstream libraries using partial compilation. This is unfortunate as it means that the declared names become effectively public even if the library author was intentionally exporting it using an alias. This commit does not address this problem; it is expected that this should not result in widespread issues across the library ecosystem. Fixes #41277 PR Close #41866
1 parent 2cc7352 commit 35450c7

File tree

11 files changed

+294
-14
lines changed

11 files changed

+294
-14
lines changed

packages/compiler-cli/src/ngtsc/imports/src/emitter.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {UnifiedModulesHost} from '../../core/api';
1212
import {absoluteFromSourceFile, dirname, LogicalFileSystem, LogicalProjectPath, relative, toRelativeImport} from '../../file_system';
1313
import {stripExtension} from '../../file_system/src/util';
1414
import {DeclarationNode, ReflectionHost} from '../../reflection';
15-
import {getSourceFile, isDeclaration, isTypeDeclaration, nodeNameForError} from '../../util/src/typescript';
15+
import {getSourceFile, isDeclaration, isNamedDeclaration, isTypeDeclaration, nodeNameForError} from '../../util/src/typescript';
1616

1717
import {findExportedNameOfNode} from './find_export';
1818
import {Reference} from './references';
@@ -255,9 +255,20 @@ export class AbsoluteModuleStrategy implements ReferenceEmitStrategy {
255255
return null;
256256
}
257257
const exportMap = new Map<DeclarationNode, string>();
258-
exports.forEach((declaration, name) => {
258+
for (const [name, declaration] of exports) {
259+
if (exportMap.has(declaration.node)) {
260+
// An export for this declaration has already been registered. We prefer an export that
261+
// has the same name as the declared name, i.e. is not an aliased export. This is relevant
262+
// for partial compilations where emitted references should import symbols using a stable
263+
// name. This is particularly relevant for declarations inside VE-generated libraries, as
264+
// such libraries contain private, unstable reexports of symbols.
265+
const existingExport = exportMap.get(declaration.node)!;
266+
if (isNamedDeclaration(declaration.node) && declaration.node.name.text === existingExport) {
267+
continue;
268+
}
269+
}
259270
exportMap.set(declaration.node, name);
260-
});
271+
}
261272
return {module: entryPointFile, exportMap};
262273
}
263274
}

packages/compiler-cli/src/ngtsc/imports/src/find_export.ts

+19-7
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import * as ts from 'typescript';
1010
import {ReflectionHost} from '../../reflection';
11+
import {isNamedDeclaration} from '../../util/src/typescript';
1112

1213
/**
1314
* Find the name, if any, by which a node is exported from a given file.
@@ -18,18 +19,29 @@ export function findExportedNameOfNode(
1819
if (exports === null) {
1920
return null;
2021
}
22+
23+
const declaredName = isNamedDeclaration(target) ? target.name.text : null;
24+
2125
// Look for the export which declares the node.
22-
const keys = Array.from(exports.keys());
23-
const name = keys.find(key => {
24-
const decl = exports.get(key);
25-
return decl !== undefined && decl.node === target;
26-
});
26+
let foundExportName: string|null = null;
27+
for (const [exportName, declaration] of exports) {
28+
if (declaration.node !== target) {
29+
continue;
30+
}
31+
32+
if (exportName === declaredName) {
33+
// A non-alias export exists which is always preferred, so use that one.
34+
return exportName;
35+
}
36+
37+
foundExportName = exportName;
38+
}
2739

28-
if (name === undefined) {
40+
if (foundExportName === null) {
2941
throw new Error(
3042
`Failed to find exported name of node (${target.getText()}) in '${file.fileName}'.`);
3143
}
32-
return name;
44+
return foundExportName;
3345
}
3446

3547
/**

packages/compiler-cli/src/ngtsc/imports/test/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ ts_library(
1111
deps = [
1212
"//packages:types",
1313
"//packages/compiler",
14+
"//packages/compiler-cli/src/ngtsc/core:api",
1415
"//packages/compiler-cli/src/ngtsc/file_system",
1516
"//packages/compiler-cli/src/ngtsc/file_system/testing",
1617
"//packages/compiler-cli/src/ngtsc/imports",

packages/compiler-cli/src/ngtsc/imports/test/emitter_spec.ts

+141-2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
*/
88
import {ExternalExpr} from '@angular/compiler';
99
import * as ts from 'typescript';
10+
import {UnifiedModulesHost} from '../../core/api';
1011

11-
import {absoluteFrom as _, LogicalFileSystem} from '../../file_system';
12+
import {absoluteFrom as _, basename, LogicalFileSystem} from '../../file_system';
1213
import {runInEachFileSystem, TestFile} from '../../file_system/testing';
1314
import {Declaration, TypeScriptReflectionHost} from '../../reflection';
1415
import {getDeclaration, makeProgram} from '../../testing';
15-
import {AbsoluteModuleStrategy, ImportFlags, LogicalProjectStrategy} from '../src/emitter';
16+
import {AbsoluteModuleStrategy, ImportFlags, LogicalProjectStrategy, RelativePathStrategy, UnifiedModulesStrategy} from '../src/emitter';
1617
import {Reference} from '../src/references';
1718
import {ModuleResolver} from '../src/resolver';
1819

@@ -49,6 +50,41 @@ runInEachFileSystem(() => {
4950
expect(emitted).toBeNull();
5051
});
5152

53+
it('should prefer non-aliased exports', () => {
54+
const {strategy, program} = makeStrategy([
55+
{
56+
name: _('/node_modules/external.d.ts'),
57+
contents: `
58+
declare class Foo {}
59+
export {Foo as A};
60+
export {Foo};
61+
export {Foo as B};
62+
`,
63+
},
64+
{
65+
name: _('/context.ts'),
66+
contents: 'export class Context {}',
67+
},
68+
]);
69+
const decl =
70+
getDeclaration(program, _('/node_modules/external.d.ts'), 'Foo', ts.isClassDeclaration);
71+
const context = program.getSourceFile(_('/context.ts'))!;
72+
73+
const reference = new Reference(decl, {
74+
specifier: 'external',
75+
resolutionContext: context.fileName,
76+
});
77+
const emitted = strategy.emit(reference, context, ImportFlags.None);
78+
if (emitted === null) {
79+
return fail('Reference should be emitted');
80+
}
81+
if (!(emitted.expression instanceof ExternalExpr)) {
82+
return fail('Reference should be emitted as ExternalExpr');
83+
}
84+
expect(emitted.expression.value.name).toEqual('Foo');
85+
expect(emitted.expression.value.moduleName).toEqual('external');
86+
});
87+
5288
it('should generate an import using the exported name of the declaration', () => {
5389
const {strategy, program} = makeStrategy([
5490
{
@@ -173,5 +209,108 @@ runInEachFileSystem(() => {
173209
// Expect the prefixed name from the TestHost.
174210
expect((ref!.expression as ExternalExpr).value.name).toEqual('testFoo');
175211
});
212+
213+
it('should prefer non-aliased exports', () => {
214+
const {program, host} = makeProgram([
215+
{
216+
name: _('/index.ts'),
217+
contents: `
218+
declare class Foo {}
219+
export {Foo as A};
220+
export {Foo};
221+
export {Foo as B};
222+
`,
223+
},
224+
{
225+
name: _('/context.ts'),
226+
contents: 'export class Context {}',
227+
}
228+
]);
229+
const checker = program.getTypeChecker();
230+
const logicalFs = new LogicalFileSystem([_('/')], host);
231+
const strategy = new LogicalProjectStrategy(new TypeScriptReflectionHost(checker), logicalFs);
232+
const decl = getDeclaration(program, _('/index.ts'), 'Foo', ts.isClassDeclaration);
233+
const context = program.getSourceFile(_('/context.ts'))!;
234+
const emitted = strategy.emit(new Reference(decl), context);
235+
if (emitted === null) {
236+
return fail('Reference should be emitted');
237+
}
238+
if (!(emitted.expression instanceof ExternalExpr)) {
239+
return fail('Reference should be emitted as ExternalExpr');
240+
}
241+
expect(emitted.expression.value.name).toEqual('Foo');
242+
expect(emitted.expression.value.moduleName).toEqual('./index');
243+
});
244+
});
245+
246+
describe('RelativePathStrategy', () => {
247+
it('should prefer non-aliased exports', () => {
248+
const {program} = makeProgram([
249+
{
250+
name: _('/index.ts'),
251+
contents: `
252+
declare class Foo {}
253+
export {Foo as A};
254+
export {Foo};
255+
export {Foo as B};
256+
`,
257+
},
258+
{
259+
name: _('/context.ts'),
260+
contents: 'export class Context {}',
261+
}
262+
]);
263+
const checker = program.getTypeChecker();
264+
const strategy = new RelativePathStrategy(new TypeScriptReflectionHost(checker));
265+
const decl = getDeclaration(program, _('/index.ts'), 'Foo', ts.isClassDeclaration);
266+
const context = program.getSourceFile(_('/context.ts'))!;
267+
const emitted = strategy.emit(new Reference(decl), context);
268+
if (emitted === null) {
269+
return fail('Reference should be emitted');
270+
}
271+
if (!(emitted.expression instanceof ExternalExpr)) {
272+
return fail('Reference should be emitted as ExternalExpr');
273+
}
274+
expect(emitted.expression.value.name).toEqual('Foo');
275+
expect(emitted.expression.value.moduleName).toEqual('./index');
276+
});
277+
});
278+
279+
describe('UnifiedModulesStrategy', () => {
280+
it('should prefer non-aliased exports', () => {
281+
const {program} = makeProgram([
282+
{
283+
name: _('/index.ts'),
284+
contents: `
285+
declare class Foo {}
286+
export {Foo as A};
287+
export {Foo};
288+
export {Foo as B};
289+
`,
290+
},
291+
{
292+
name: _('/context.ts'),
293+
contents: 'export class Context {}',
294+
}
295+
]);
296+
const checker = program.getTypeChecker();
297+
const host: UnifiedModulesHost = {
298+
fileNameToModuleName(importedFilePath): string {
299+
return basename(importedFilePath, '.ts');
300+
}
301+
};
302+
const strategy = new UnifiedModulesStrategy(new TypeScriptReflectionHost(checker), host);
303+
const decl = getDeclaration(program, _('/index.ts'), 'Foo', ts.isClassDeclaration);
304+
const context = program.getSourceFile(_('/context.ts'))!;
305+
const emitted = strategy.emit(new Reference(decl), context);
306+
if (emitted === null) {
307+
return fail('Reference should be emitted');
308+
}
309+
if (!(emitted.expression instanceof ExternalExpr)) {
310+
return fail('Reference should be emitted as ExternalExpr');
311+
}
312+
expect(emitted.expression.value.name).toEqual('Foo');
313+
expect(emitted.expression.value.moduleName).toEqual('index');
314+
});
176315
});
177316
});

packages/compiler-cli/src/ngtsc/util/src/typescript.ts

+5
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export function isTypeDeclaration(node: ts.Node): node is ts.EnumDeclaration|
8383
ts.isInterfaceDeclaration(node);
8484
}
8585

86+
export function isNamedDeclaration(node: ts.Node): node is ts.Declaration&{name: ts.Identifier} {
87+
const namedNode = node as {name?: ts.Identifier};
88+
return namedNode.name !== undefined && ts.isIdentifier(namedNode.name);
89+
}
90+
8691
export function isExported(node: DeclarationNode): boolean {
8792
let topLevel: ts.Node = node;
8893
if (ts.isVariableDeclaration(node) && ts.isVariableDeclarationList(node.parent)) {

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/GOLDEN_PARTIAL.js

+52
Original file line numberDiff line numberDiff line change
@@ -567,3 +567,55 @@ export declare class TestCmp {
567567
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmp, "test-cmp", never, {}, {}, never, never>;
568568
}
569569

570+
/****************************************************************************************************
571+
* PARTIAL FILE: library_exports.js
572+
****************************************************************************************************/
573+
// This test verifies that a directive from an external library is emitted using its declared name,
574+
// even in the presence of alias exports that could have been chosen.
575+
// See https://github.com/angular/angular/issues/41277.
576+
import { Component, NgModule } from '@angular/core';
577+
import { LibModule } from 'external_library';
578+
import * as i0 from "@angular/core";
579+
import * as i1 from "external_library";
580+
export class TestComponent {
581+
}
582+
TestComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
583+
TestComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, selector: "ng-component", ngImport: i0, template: `
584+
<lib-dir></lib-dir>
585+
`, isInline: true, directives: [{ type: i1.LibDirective, selector: "lib-dir" }] });
586+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{
587+
type: Component,
588+
args: [{
589+
template: `
590+
<lib-dir></lib-dir>
591+
`,
592+
}]
593+
}] });
594+
export class TestModule {
595+
}
596+
TestModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
597+
TestModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestModule, declarations: [TestComponent], imports: [LibModule] });
598+
TestModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestModule, imports: [[LibModule]] });
599+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestModule, decorators: [{
600+
type: NgModule,
601+
args: [{
602+
declarations: [TestComponent],
603+
imports: [LibModule],
604+
}]
605+
}] });
606+
607+
/****************************************************************************************************
608+
* PARTIAL FILE: library_exports.d.ts
609+
****************************************************************************************************/
610+
import * as i0 from "@angular/core";
611+
import * as i1 from "external_library";
612+
export declare class TestComponent {
613+
static ɵfac: i0.ɵɵFactoryDeclaration<TestComponent, never>;
614+
static ɵcmp: i0.ɵɵComponentDeclaration<TestComponent, "ng-component", never, {}, {}, never, never>;
615+
}
616+
export declare class TestModule {
617+
static ɵfac: i0.ɵɵFactoryDeclaration<TestModule, never>;
618+
static ɵmod: i0.ɵɵNgModuleDeclaration<TestModule, [typeof TestComponent], [typeof i1.LibModule], never>;
619+
static ɵinj: i0.ɵɵInjectorDeclaration<TestModule>;
620+
}
621+

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/TEST_CASES.json

+21
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,27 @@
178178
"inputFiles": [
179179
"non_literal_template_with_concatenation.ts"
180180
]
181+
},
182+
{
183+
"description": "should not use reexport names inside declarations when a direct export is available",
184+
"inputFiles": [
185+
"external_library.d.ts",
186+
"library_exports.ts"
187+
],
188+
"expectations": [
189+
{
190+
"failureMessage": "Invalid list of directives",
191+
"files": [
192+
"library_exports.js"
193+
]
194+
}
195+
],
196+
"compilerOptions": {
197+
"baseUrl": ".",
198+
"paths": {
199+
"external_library": ["./external_library"]
200+
}
201+
}
181202
}
182203
]
183204
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as ɵngcc0 from '@angular/core';
2+
3+
declare class LibDirective {
4+
static ɵfac: ɵngcc0.ɵɵFactoryDeclaration<LibDirective, never>;
5+
static ɵdir: ɵngcc0.ɵɵDirectiveDeclaration<LibDirective, 'lib-dir', never, {}, {}, never>;
6+
}
7+
8+
export declare class LibModule {
9+
static ɵfac: ɵngcc0.ɵɵFactoryDeclaration<LibModule, never>;
10+
static ɵmod: ɵngcc0.ɵɵNgModuleDeclaration<LibModule, [typeof LibDirective], never, [typeof LibDirective]>;
11+
static ɵinj: ɵngcc0.ɵɵInjectorDeclaration<LibModule>;
12+
}
13+
14+
export {LibDirective as ɵangular_packages_forms_forms_a}
15+
export {LibDirective}
16+
export {LibDirective as ɵangular_packages_forms_forms_b}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// NOTE: The declared name must have been used to refer to `LibDirective`, as the aliased exports
2+
// NOTE: are not stable and therefore not suitable for partial compilation outputs.
3+
directives: [$i0$.LibDirective]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// This test verifies that a directive from an external library is emitted using its declared name,
2+
// even in the presence of alias exports that could have been chosen.
3+
// See https://github.com/angular/angular/issues/41277.
4+
import {Component, NgModule} from '@angular/core';
5+
import {LibModule} from 'external_library';
6+
7+
@Component({
8+
template: `
9+
<lib-dir></lib-dir>
10+
`,
11+
})
12+
export class TestComponent {
13+
}
14+
15+
@NgModule({
16+
declarations: [TestComponent],
17+
imports: [LibModule],
18+
})
19+
export class TestModule {
20+
}

0 commit comments

Comments
 (0)