diff --git a/packages/compiler-cli/test/ngtsc/ls_typecheck_helpers_spec.ts b/packages/compiler-cli/test/ngtsc/ls_typecheck_helpers_spec.ts index d658d864306..f7e53951ac7 100644 --- a/packages/compiler-cli/test/ngtsc/ls_typecheck_helpers_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ls_typecheck_helpers_spec.ts @@ -270,6 +270,7 @@ runInEachFileSystem(() => { expect(imports.length).toBe(1); expect(imports[0].moduleSpecifier).toBe('./two'); expect(imports[0].symbolName).toBe('TwoCmp'); + expect(imports[0].isForwardReference).toBe(false); }); it('for out of scope ngModules', () => { @@ -326,6 +327,41 @@ runInEachFileSystem(() => { expect(imports.length).toBe(1); expect(imports[0].moduleSpecifier).toBe('./twomod'); expect(imports[0].symbolName).toBe('TwoModule'); + expect(imports[0].isForwardReference).toBe(false); + }); + + it('for forward references in the same file', () => { + env.write('decls.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'one-cmp', + template: '
', + }) + export class OneCmp {} + + @Component({ + standalone: true, + selector: 'two-cmp', + template: '
', + }) + export class TwoCmp {} + `); + const {program, checker} = env.driveTemplateTypeChecker(); + const sfOne = program.getSourceFile(_('/decls.ts')); + expect(sfOne).not.toBeNull(); + const OneCmpClass = getClass(sfOne!, 'OneCmp'); + + const TwoCmpDir = checker.getPotentialTemplateDirectives(OneCmpClass) + .filter(d => d.selector === 'two-cmp')[0]; + const imports = + checker.getPotentialImportsFor(TwoCmpDir.ref, OneCmpClass, PotentialImportMode.Normal); + + expect(imports.length).toBe(1); + expect(imports[0].moduleSpecifier).toBeUndefined(); + expect(imports[0].symbolName).toBe('TwoCmp'); + expect(imports[0].isForwardReference).toBe(true); }); }); }); diff --git a/packages/language-service/src/codefixes/fix_missing_import.ts b/packages/language-service/src/codefixes/fix_missing_import.ts index 8edb2fc0ff8..ca92f09cd63 100644 --- a/packages/language-service/src/codefixes/fix_missing_import.ts +++ b/packages/language-service/src/codefixes/fix_missing_import.ts @@ -74,11 +74,33 @@ function getCodeActions( const currMatchSymbol = currMatch.tsSymbol.valueDeclaration!; const potentialImports = checker.getPotentialImportsFor(currMatch.ref, importOn, PotentialImportMode.Normal); - for (let potentialImport of potentialImports) { - let [fileImportChanges, importName] = updateImportsForTypescriptFile( - tsChecker, importOn.getSourceFile(), potentialImport, currMatchSymbol.getSourceFile()); + for (const potentialImport of potentialImports) { + const fileImportChanges: ts.TextChange[] = []; + let importName: string; + let forwardRefName: string|null = null; + + if (potentialImport.moduleSpecifier) { + const [importChanges, generatedImportName] = updateImportsForTypescriptFile( + tsChecker, importOn.getSourceFile(), potentialImport.symbolName, + potentialImport.moduleSpecifier, currMatchSymbol.getSourceFile()); + importName = generatedImportName; + fileImportChanges.push(...importChanges); + } else { + if (potentialImport.isForwardReference) { + // Note that we pass the `importOn` file twice since we know that the potential import + // is within the same file, because it doesn't have a `moduleSpecifier`. + const [forwardRefImports, generatedForwardRefName] = updateImportsForTypescriptFile( + tsChecker, importOn.getSourceFile(), 'forwardRef', '@angular/core', + importOn.getSourceFile()); + fileImportChanges.push(...forwardRefImports); + forwardRefName = generatedForwardRefName; + } + importName = potentialImport.symbolName; + } + // Always update the trait import, although the TS import might already be present. - let traitImportChanges = updateImportsForAngularTrait(checker, importOn, importName); + const traitImportChanges = + updateImportsForAngularTrait(checker, importOn, importName, forwardRefName); if (traitImportChanges.length === 0) continue; let description = `Import ${importName}`; diff --git a/packages/language-service/src/ts_utils.ts b/packages/language-service/src/ts_utils.ts index 3333d3e7af7..c938c6ead28 100644 --- a/packages/language-service/src/ts_utils.ts +++ b/packages/language-service/src/ts_utils.ts @@ -178,14 +178,15 @@ export function updateObjectValueForKey( * If no update is needed, returns `null`. */ export function ensureArrayWithIdentifier( - identifier: ts.Identifier, arr?: ts.ArrayLiteralExpression): ts.ArrayLiteralExpression|null { + identifierText: string, expression: ts.Expression, + arr?: ts.ArrayLiteralExpression): ts.ArrayLiteralExpression|null { if (arr === undefined) { - return ts.factory.createArrayLiteralExpression([identifier]); + return ts.factory.createArrayLiteralExpression([expression]); } - if (arr.elements.find(v => ts.isIdentifier(v) && v.text === identifier.text)) { + if (arr.elements.find(v => ts.isIdentifier(v) && v.text === identifierText)) { return null; } - return ts.factory.updateArrayLiteralExpression(arr, [...arr.elements, identifier]); + return ts.factory.updateArrayLiteralExpression(arr, [...arr.elements, expression]); } export function moduleSpecifierPointsToFile( @@ -316,18 +317,13 @@ export function standaloneTraitOrNgModule( * Returns the text changes, as well as the name with which the imported symbol can be referred to. */ export function updateImportsForTypescriptFile( - tsChecker: ts.TypeChecker, file: ts.SourceFile, newImport: PotentialImport, + tsChecker: ts.TypeChecker, file: ts.SourceFile, symbolName: string, moduleSpecifier: string, tsFileToImport: ts.SourceFile): [ts.TextChange[], string] { - // If the expression is already imported, we can just return its name. - if (newImport.moduleSpecifier === undefined) { - return [[], newImport.symbolName]; - } - // The trait might already be imported, possibly under a different name. If so, determine the // local name of the imported trait. const allImports = findAllMatchingNodes(file, {filter: ts.isImportDeclaration}); const existingImportName: string|null = - hasImport(tsChecker, allImports, newImport.symbolName, tsFileToImport); + hasImport(tsChecker, allImports, symbolName, tsFileToImport); if (existingImportName !== null) { return [[], existingImportName]; } @@ -335,7 +331,7 @@ export function updateImportsForTypescriptFile( // If the trait has not already been imported, we need to insert the new import. const existingImportDeclaration = allImports.find( decl => moduleSpecifierPointsToFile(tsChecker, decl.moduleSpecifier, tsFileToImport)); - const importName = nonCollidingImportName(allImports, newImport.symbolName); + const importName = nonCollidingImportName(allImports, symbolName); if (existingImportDeclaration !== undefined) { // Update an existing import declaration. @@ -347,7 +343,7 @@ export function updateImportsForTypescriptFile( return [[], '']; } let span = {start: bindings.getStart(), length: bindings.getWidth()}; - const updatedBindings = updateImport(bindings, newImport.symbolName, importName); + const updatedBindings = updateImport(bindings, symbolName, importName); const importString = printNode(updatedBindings, file); return [[{span, newText: importString}], importName]; } @@ -364,8 +360,7 @@ export function updateImportsForTypescriptFile( if (lastImport as any !== null) { // TODO: Why does the compiler insist this is null? span.start = lastImport!.getStart() + lastImport!.getWidth(); } - const newImportDeclaration = - generateImport(newImport.symbolName, importName, newImport.moduleSpecifier); + const newImportDeclaration = generateImport(symbolName, importName, moduleSpecifier); const importString = '\n' + printNode(newImportDeclaration, file); return [[{span, newText: importString}], importName]; } @@ -375,7 +370,8 @@ export function updateImportsForTypescriptFile( * `importName` to the list of imports on the decorator arguments. */ export function updateImportsForAngularTrait( - checker: TemplateTypeChecker, trait: ts.ClassDeclaration, importName: string): ts.TextChange[] { + checker: TemplateTypeChecker, trait: ts.ClassDeclaration, importName: string, + forwardRefName: string|null): ts.TextChange[] { // Get the object with arguments passed into the primary Angular decorator for this trait. const decorator = checker.getPrimaryAngularDecorator(trait); if (decorator === null) { @@ -393,7 +389,14 @@ export function updateImportsForAngularTrait( if (oldValue && !ts.isArrayLiteralExpression(oldValue)) { return oldValue; } - const newArr = ensureArrayWithIdentifier(ts.factory.createIdentifier(importName), oldValue); + const identifier = ts.factory.createIdentifier(importName); + const expression = forwardRefName ? + ts.factory.createCallExpression( + ts.factory.createIdentifier(forwardRefName), undefined, + [ts.factory.createArrowFunction( + undefined, undefined, [], undefined, undefined, identifier)]) : + identifier; + const newArr = ensureArrayWithIdentifier(importName, expression, oldValue); updateRequired = newArr !== null; return newArr!; }); diff --git a/packages/language-service/test/ts_utils_spec.ts b/packages/language-service/test/ts_utils_spec.ts index 9ab9b4e56de..ac5cd5e806f 100644 --- a/packages/language-service/test/ts_utils_spec.ts +++ b/packages/language-service/test/ts_utils_spec.ts @@ -163,17 +163,17 @@ describe('TS util', () => { }); it('addElementToArrayLiteral', () => { - let arr = ensureArrayWithIdentifier(ts.factory.createIdentifier('foo')); + let arr = ensureArrayWithIdentifier('foo', ts.factory.createIdentifier('foo')); arr = addElementToArrayLiteral(arr!, ts.factory.createIdentifier('bar')); expect(print(arr)).toEqual('[foo, bar]'); }); it('ensureArrayWithIdentifier', () => { - let arr = ensureArrayWithIdentifier(ts.factory.createIdentifier('foo')); + let arr = ensureArrayWithIdentifier('foo', ts.factory.createIdentifier('foo')); expect(print(arr!)).toEqual('[foo]'); - arr = ensureArrayWithIdentifier(ts.factory.createIdentifier('bar'), arr!); + arr = ensureArrayWithIdentifier('bar', ts.factory.createIdentifier('bar'), arr!); expect(print(arr!)).toEqual('[foo, bar]'); - arr = ensureArrayWithIdentifier(ts.factory.createIdentifier('bar'), arr!); + arr = ensureArrayWithIdentifier('bar', ts.factory.createIdentifier('bar'), arr!); expect(arr).toEqual(null); });