diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts index e0ac434c4b..36701e189d 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts @@ -39,8 +39,13 @@ export function generateSetClassMetadataCall( } const ngClassDecorators = classDecorators.filter(dec => isAngularDecorator(dec, isCore)) - .map( - (decorator: Decorator) => decoratorToMetadata(decorator, annotateForClosureCompiler)); + .map(decorator => decoratorToMetadata(decorator, annotateForClosureCompiler)) + // Since the `setClassMetadata` call is intended to be emitted after the class + // declaration, we have to strip references to the existing identifiers or + // TypeScript might generate invalid code when it emits to JS. In particular + // this can break when emitting a class to ES5 which has a custom decorator + // and is referenced inside of its own metadata (see #39509 for more information). + .map(decorator => removeIdentifierReferences(decorator, id.text)); if (ngClassDecorators.length === 0) { return null; } @@ -166,3 +171,19 @@ function decoratorToMetadata( function isAngularDecorator(decorator: Decorator, isCore: boolean): boolean { return isCore || (decorator.import !== null && decorator.import.from === '@angular/core'); } + +/** + * Recursively recreates all of the `Identifier` descendant nodes with a particular name inside + * of an AST node, thus removing any references to them. Useful if a particular node has to be t + * aken from one place any emitted to another one exactly as it has been written. + */ +function removeIdentifierReferences(node: T, name: string): T { + const result = ts.transform( + node, [context => root => ts.visitNode(root, function walk(current: ts.Node): ts.Node { + return ts.isIdentifier(current) && current.text === name ? + ts.createIdentifier(current.text) : + ts.visitEachChild(current, walk, context); + })]); + + return result.transformed[0]; +} diff --git a/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts b/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts index 157b724db0..6241df64e3 100644 --- a/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_compiler_compliance_spec.ts @@ -8,6 +8,7 @@ import {AttributeMarker} from '@angular/compiler/src/core'; import {setup} from '@angular/compiler/test/aot/test_util'; +import * as ts from 'typescript'; import {compile, expectEmit} from './mock_compile'; @@ -3313,5 +3314,46 @@ describe('compiler compliance', () => { const result = compile(files, angularFiles); expectEmit(result.source, MyAppDeclaration, 'Invalid component definition'); }); + + it('should emit a valid setClassMetadata call in ES5 if a class with a custom decorator is referencing itself inside its own metadata', + () => { + const files = { + app: { + 'spec.ts': ` + import {Component, InjectionToken} from "@angular/core"; + + const token = new InjectionToken('token'); + + export function Custom() { + return function(target: any) {}; + } + + @Custom() + @Component({ + template: '', + providers: [{ provide: token, useExisting: Comp }], + }) + export class Comp {} + ` + } + }; + + // The setClassMetadata call should look like this. + const setClassMetadata = ` + … + (function() { + i0.ɵsetClassMetadata(Comp, [{ + type: Component, + args: [{ + template: '', + providers: [{ provide: token, useExisting: Comp }], + }] + }], null, null); + })(); + `; + + const result = compile(files, angularFiles, {target: ts.ScriptTarget.ES5}); + expectEmit(result.source, setClassMetadata, 'Incorrect setClassMetadata call'); + }); }); });