Skip to content

Commit fd90f10

Browse files
clydinmgechev
authored andcommitted
fix(@ngtools/webpack): ensure references for constructor parameter types
By cloning the import specifier for each of the parameter types, TypeScript's import eliding algorithm will skip removal and prevent reference exceptions at runtime.
1 parent d44c6ac commit fd90f10

File tree

2 files changed

+83
-123
lines changed

2 files changed

+83
-123
lines changed

packages/ngtools/webpack/src/transformers/ctor-parameters.ts

+59-123
Original file line numberDiff line numberDiff line change
@@ -202,49 +202,31 @@ export function decoratorDownlevelTransformer(
202202
diagnostics: ts.Diagnostic[],
203203
): (context: ts.TransformationContext) => ts.Transformer<ts.SourceFile> {
204204
return (context: ts.TransformationContext) => {
205-
/** A map from symbols to the identifier of an import, reset per SourceFile. */
206-
let importNamesBySymbol = new Map<ts.Symbol, ts.Identifier>();
205+
const parameterTypeSymbols = new Set<ts.Symbol>();
207206

208207
/**
209208
* Converts an EntityName (from a type annotation) to an expression (accessing a value).
210209
*
211210
* For a given ts.EntityName, this walks depth first to find the leftmost ts.Identifier, then
212211
* converts the path into property accesses.
213212
*
214-
* This generally works, but TypeScript's emit pipeline does not serialize identifiers that are
215-
* only used in a type location (such as identifiers in a TypeNode), even if the identifier
216-
* itself points to a value (e.g. a class). To avoid that problem, this method finds the symbol
217-
* representing the identifier (using typeChecker), then looks up where it was imported (using
218-
* importNamesBySymbol), and then uses the imported name instead of the identifier from the type
219-
* expression, if any. Otherwise it'll use the identifier unchanged. This makes sure the
220-
* identifier is not marked as stemming from a "type only" expression, causing it to be emitted
221-
* and causing the import to be retained.
222213
*/
223214
function entityNameToExpression(name: ts.EntityName): ts.Expression | undefined {
224-
const sym = typeChecker.getSymbolAtLocation(name);
225-
if (!sym) {
226-
return undefined;
227-
}
228-
// Check if the entity name references a symbol that is an actual value. If it is not, it
229-
// cannot be referenced by an expression, so return undefined.
230-
let symToCheck = sym;
231-
if (symToCheck.flags & ts.SymbolFlags.Alias) {
232-
symToCheck = typeChecker.getAliasedSymbol(symToCheck);
233-
}
234-
if (!(symToCheck.flags & ts.SymbolFlags.Value)) {
235-
return undefined;
236-
}
237-
238215
if (ts.isIdentifier(name)) {
239-
// If there's a known import name for this symbol, use it so that the import will be
240-
// retained and the value can be referenced.
241-
const value = importNamesBySymbol.get(sym);
242-
if (value) {
243-
return value;
216+
const typeSymbol = typeChecker.getSymbolAtLocation(name);
217+
if (typeSymbol) {
218+
parameterTypeSymbols.add(typeSymbol);
244219
}
245220

246-
// Otherwise this will be a locally declared name, just return that.
247-
return name;
221+
// Based on TS's strategy to allow the checker to reach this identifier
222+
// tslint:disable-next-line:max-line-length
223+
// https://github.com/microsoft/TypeScript/blob/7f47a08a5e9874f0f97a667bd81eebddec61247c/src/compiler/transformers/ts.ts#L2093
224+
const exp = ts.getMutableClone(name);
225+
exp.flags &= ~ts.NodeFlags.Synthesized;
226+
((exp as unknown) as { original: undefined }).original = undefined;
227+
exp.parent = ts.getParseTreeNode(name.getSourceFile());
228+
229+
return exp;
248230
}
249231
const ref = entityNameToExpression(name.left);
250232
if (!ref) {
@@ -254,17 +236,13 @@ export function decoratorDownlevelTransformer(
254236
return ts.createPropertyAccess(ref, name.right);
255237
}
256238

257-
/**
258-
* Transforms a constructor. Returns the transformed constructor and the list of parameter
259-
* information collected, consisting of decorators and optional type.
260-
*/
261-
function transformConstructor(
262-
ctor: ts.ConstructorDeclaration,
263-
): [ts.ConstructorDeclaration, ParameterDecorationInfo[]] {
264-
ctor = ts.visitEachChild(ctor, visitor, context);
239+
function classMemberVisitor(node: ts.Node): ts.VisitResult<ts.Node> {
240+
if (!ts.isConstructorDeclaration(node) || !node.body) {
241+
return visitor(node);
242+
}
265243

266244
const parametersInfo: ParameterDecorationInfo[] = [];
267-
for (const param of ctor.parameters) {
245+
for (const param of node.parameters) {
268246
const paramInfo: ParameterDecorationInfo = { decorators: [], type: null };
269247

270248
for (const decorator of param.decorators || []) {
@@ -280,98 +258,56 @@ export function decoratorDownlevelTransformer(
280258
parametersInfo.push(paramInfo);
281259
}
282260

283-
return [ctor, parametersInfo];
284-
}
285-
286-
/**
287-
* Transforms a single class declaration:
288-
* - creates a ctorParameters property
289-
*/
290-
function transformClassDeclaration(classDecl: ts.ClassDeclaration): ts.ClassDeclaration {
291-
if (!classDecl.decorators || classDecl.decorators.length === 0) {
292-
return classDecl;
293-
}
294-
295-
const newMembers: ts.ClassElement[] = [];
296-
let classParameters: ParameterDecorationInfo[] | null = null;
297-
298-
for (const member of classDecl.members) {
299-
switch (member.kind) {
300-
case ts.SyntaxKind.Constructor: {
301-
const ctor = member as ts.ConstructorDeclaration;
302-
if (!ctor.body) {
303-
break;
304-
}
261+
if (parametersInfo.length > 0) {
262+
const ctorProperty = createCtorParametersClassProperty(
263+
diagnostics,
264+
entityNameToExpression,
265+
parametersInfo,
266+
);
305267

306-
const [newMember, parametersInfo] = transformConstructor(
307-
member as ts.ConstructorDeclaration,
308-
);
309-
classParameters = parametersInfo;
310-
newMembers.push(newMember);
311-
continue;
312-
}
313-
default:
314-
break;
315-
}
316-
newMembers.push(ts.visitEachChild(member, visitor, context));
268+
return [node, ctorProperty];
269+
} else {
270+
return node;
317271
}
272+
}
318273

319-
const newClassDeclaration = ts.getMutableClone(classDecl);
320-
321-
if (classParameters) {
322-
newMembers.push(
323-
createCtorParametersClassProperty(diagnostics, entityNameToExpression, classParameters),
274+
function visitor<T extends ts.Node>(node: T): ts.Node {
275+
if (ts.isClassDeclaration(node)) {
276+
return ts.updateClassDeclaration(
277+
node,
278+
node.decorators,
279+
node.modifiers,
280+
node.name,
281+
node.typeParameters,
282+
node.heritageClauses,
283+
ts.visitNodes(node.members, classMemberVisitor),
324284
);
285+
} else {
286+
return ts.visitEachChild(node, visitor, context);
325287
}
326-
327-
newClassDeclaration.members = ts.setTextRange(
328-
ts.createNodeArray(newMembers, newClassDeclaration.members.hasTrailingComma),
329-
classDecl.members,
330-
);
331-
332-
return newClassDeclaration;
333288
}
334289

335-
function visitor(node: ts.Node): ts.Node {
336-
switch (node.kind) {
337-
case ts.SyntaxKind.SourceFile: {
338-
importNamesBySymbol = new Map<ts.Symbol, ts.Identifier>();
339-
340-
return ts.visitEachChild(node, visitor, context);
341-
}
342-
case ts.SyntaxKind.ImportDeclaration: {
343-
const impDecl = node as ts.ImportDeclaration;
344-
if (impDecl.importClause) {
345-
const importClause = impDecl.importClause;
346-
const names = [];
347-
if (importClause.name) {
348-
names.push(importClause.name);
349-
}
350-
if (
351-
importClause.namedBindings &&
352-
importClause.namedBindings.kind === ts.SyntaxKind.NamedImports
353-
) {
354-
const namedImports = importClause.namedBindings as ts.NamedImports;
355-
names.push(...namedImports.elements.map(e => e.name));
356-
}
357-
for (const name of names) {
358-
const sym = typeChecker.getSymbolAtLocation(name);
359-
if (sym) {
360-
importNamesBySymbol.set(sym, name);
361-
}
290+
return (sf: ts.SourceFile) => {
291+
parameterTypeSymbols.clear();
292+
293+
return ts.visitEachChild(
294+
visitor(sf) as ts.SourceFile,
295+
function visitImports(node: ts.Node): ts.Node {
296+
if (
297+
(ts.isImportSpecifier(node) || ts.isNamespaceImport(node) || ts.isImportClause(node)) &&
298+
node.name
299+
) {
300+
const importSymbol = typeChecker.getSymbolAtLocation(node.name);
301+
if (importSymbol && parameterTypeSymbols.has(importSymbol)) {
302+
// Using a clone prevents TS from removing the import specifier
303+
return ts.getMutableClone(node);
362304
}
363305
}
364306

365-
return ts.visitEachChild(node, visitor, context);
366-
}
367-
case ts.SyntaxKind.ClassDeclaration: {
368-
return transformClassDeclaration(node as ts.ClassDeclaration);
369-
}
370-
default:
371-
return ts.visitEachChild(node, visitor, context);
372-
}
373-
}
374-
375-
return (sf: ts.SourceFile) => visitor(sf) as ts.SourceFile;
307+
return ts.visitEachChild(node, visitImports, context);
308+
},
309+
context,
310+
);
311+
};
376312
};
377313
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { prependToFile, replaceInFile, writeFile } from '../../utils/fs';
2+
import { ng } from '../../utils/process';
3+
4+
export default async function() {
5+
// Ensure an ES2015 build is used in test
6+
await writeFile('browserslist', 'Chrome 65');
7+
8+
await ng('generate', 'service', 'user');
9+
10+
// Update the application to use the new service
11+
await prependToFile(
12+
'src/app/app.component.ts',
13+
'import { UserService } from \'./user.service\';',
14+
);
15+
16+
await replaceInFile(
17+
'src/app/app.component.ts',
18+
'export class AppComponent {',
19+
'export class AppComponent {\n constructor(user: UserService) {}',
20+
);
21+
22+
// Execute the application's tests with emitDecoratorMetadata disabled (default)
23+
await ng('test', '--no-watch');
24+
}

0 commit comments

Comments
 (0)