mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
feat(language-service): Typecheck templates which would require inline typecheck blocks (#68454)
This change updates the language service to generate TCBs for templates that would previously have required inlining. The new strategy is to copy the original source and then do inlining in the external TCB. This allows language features and type-checking in templates of non-exported classes (such as test components) or classes with local, non exported dependencies. PR Close #68454
This commit is contained in:
parent
7f0265e43a
commit
4f9c824dd9
6 changed files with 283 additions and 17 deletions
|
|
@ -52,7 +52,7 @@ import {
|
|||
PipeMeta,
|
||||
} from '../../metadata';
|
||||
import {PerfCheckpoint, PerfEvent, PerfPhase, PerfRecorder} from '../../perf';
|
||||
import {ProgramDriver, UpdateMode} from '../../program_driver';
|
||||
import {ProgramDriver, UpdateMode, InliningMode} from '../../program_driver';
|
||||
import {
|
||||
ClassDeclaration,
|
||||
DeclarationNode,
|
||||
|
|
@ -118,6 +118,7 @@ import {DirectiveSourceManager} from './source';
|
|||
import {findTypeCheckBlock, getSourceMapping, TypeCheckSourceResolver} from './tcb_util';
|
||||
import {SymbolBuilder, SymbolDirectiveMeta, SymbolBoundTarget} from './template_symbol_builder';
|
||||
import {findAllMatchingNodes} from './comments';
|
||||
import {TCB_FUNCTION_PREFIX} from './type_check_file';
|
||||
|
||||
export class TypeCheckableDirectiveMetaAdapter implements SymbolDirectiveMeta {
|
||||
constructor(
|
||||
|
|
@ -285,6 +286,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
|||
* destroyed and replaced.
|
||||
*/
|
||||
private elementTagCache = new Map<ts.ClassDeclaration, Map<string, PotentialDirective | null>>();
|
||||
private generatedRangeCache = new WeakMap<ts.SourceFile, ts.TextRange[]>();
|
||||
|
||||
private isComplete = false;
|
||||
private priorResultsAdopted = false;
|
||||
|
|
@ -595,6 +597,51 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
|||
this.ensureAllShimsForAllFiles();
|
||||
}
|
||||
|
||||
private getGeneratedCodeRanges(sf: ts.SourceFile): ts.TextRange[] {
|
||||
if (this.generatedRangeCache.has(sf)) {
|
||||
return this.generatedRangeCache.get(sf)!;
|
||||
}
|
||||
|
||||
const ranges: ts.TextRange[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isInterfaceDeclaration(node)) {
|
||||
return;
|
||||
}
|
||||
if (ts.isFunctionDeclaration(node)) {
|
||||
if (node.name !== undefined) {
|
||||
const name = node.name.text;
|
||||
if (name.startsWith(TCB_FUNCTION_PREFIX)) {
|
||||
ranges.push({pos: node.getStart(), end: node.getEnd()});
|
||||
// TCBs never contain other TCBs or generated utilities, so we can skip traversing inside them.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
// We do a full AST traversal because TCBs can be generated inside closures (e.g. `it` blocks in tests)
|
||||
// so a shallow scan of top-level statements is insufficient.
|
||||
ts.forEachChild(sf, visit);
|
||||
|
||||
this.generatedRangeCache.set(sf, ranges);
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private filterShimDiagnostics(
|
||||
shimSf: ts.SourceFile,
|
||||
semanticDiagnostics: readonly ts.Diagnostic[],
|
||||
): readonly ts.Diagnostic[] {
|
||||
if (this.programDriver.inliningMode !== InliningMode.CopySourceToTcb) {
|
||||
return semanticDiagnostics;
|
||||
}
|
||||
const ranges = this.getGeneratedCodeRanges(shimSf);
|
||||
return semanticDiagnostics.filter((diag) => {
|
||||
if (diag.start === undefined) return true;
|
||||
return ranges.some((range) => diag.start! >= range.pos && diag.start! < range.end);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve type-checking and template parse diagnostics from the given `ts.SourceFile` using the
|
||||
* most recent type-checking program.
|
||||
|
|
@ -626,14 +673,13 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
|||
}
|
||||
|
||||
for (const [shimPath, shimRecord] of fileRecord.shimData) {
|
||||
// TODO(atscott): Filter out diagnostics from original source in CopySourceToTcb
|
||||
// We don't want to duplicate diagnostics from original source when copying to tcb
|
||||
|
||||
const shimSf = getSourceFileOrError(typeCheckProgram, shimPath);
|
||||
const semanticDiagnostics = typeCheckProgram.getSemanticDiagnostics(shimSf);
|
||||
|
||||
const filteredDiagnostics = this.filterShimDiagnostics(shimSf, semanticDiagnostics);
|
||||
|
||||
diagnostics.push(
|
||||
...typeCheckProgram
|
||||
.getSemanticDiagnostics(shimSf)
|
||||
.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
|
||||
...filteredDiagnostics.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
|
||||
);
|
||||
diagnostics.push(...shimRecord.genesisDiagnostics);
|
||||
|
||||
|
|
@ -715,10 +761,11 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
|||
}
|
||||
|
||||
const shimSf = getSourceFileOrError(typeCheckProgram, shimPath);
|
||||
const semanticDiagnostics = typeCheckProgram.getSemanticDiagnostics(shimSf);
|
||||
const filteredDiagnostics = this.filterShimDiagnostics(shimSf, semanticDiagnostics);
|
||||
|
||||
diagnostics.push(
|
||||
...typeCheckProgram
|
||||
.getSemanticDiagnostics(shimSf)
|
||||
.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
|
||||
...filteredDiagnostics.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
|
||||
);
|
||||
diagnostics.push(...shimRecord.genesisDiagnostics);
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import {ReferenceEmitEnvironment} from './reference_emit_environment';
|
|||
import {TypeCheckShimGenerator} from './shim';
|
||||
import {DirectiveSourceManager} from './source';
|
||||
import {requiresInlineTypeCheckBlock, TcbInliningRequirement} from './tcb_util';
|
||||
import {TypeCheckFile} from './type_check_file';
|
||||
import {TCB_FUNCTION_PREFIX, TypeCheckFile} from './type_check_file';
|
||||
import {generateInlineTypeCtor, requiresInlineTypeCtor} from './type_constructor';
|
||||
|
||||
export interface ShimTypeCheckingData {
|
||||
|
|
@ -725,7 +725,7 @@ class InlineTcbOp implements Op {
|
|||
if (tcbSf !== originalSf) {
|
||||
env.copiedSourceOriginPath = absoluteFromSourceFile(originalSf);
|
||||
}
|
||||
const fnName = `_tcb_${this.ref.node.pos}`;
|
||||
const fnName = `${TCB_FUNCTION_PREFIX}_${this.ref.node.pos}`;
|
||||
|
||||
const {tcbMeta, component} = adaptTypeCheckBlockMetadata(
|
||||
this.ref,
|
||||
|
|
@ -746,7 +746,9 @@ class InlineTcbOp implements Op {
|
|||
this.oobRecorder,
|
||||
);
|
||||
|
||||
return fn;
|
||||
// A leading newline is required so that the generated TCB isn't accidentally
|
||||
// appended as part of a trailing single-line comment (e.g. `// comment`).
|
||||
return `\n${fn}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import {Environment} from './environment';
|
|||
import {ensureTypeCheckFilePreparationImports} from './tcb_util';
|
||||
import {adaptTypeCheckBlockMetadata} from './tcb_adapter';
|
||||
|
||||
export const TCB_FUNCTION_PREFIX = '_tcb';
|
||||
|
||||
/**
|
||||
* An `Environment` representing the single type-checking file into which most (if not all) Type
|
||||
* Check Blocks (TCBs) will be generated.
|
||||
|
|
@ -79,7 +81,7 @@ export class TypeCheckFile extends Environment {
|
|||
genericContextBehavior: TcbGenericContextBehavior,
|
||||
reflector: ReflectionHost,
|
||||
): void {
|
||||
const fnId = `_tcb${this.nextTcbId++}`;
|
||||
const fnId = `${TCB_FUNCTION_PREFIX}${this.nextTcbId++}`;
|
||||
const {tcbMeta, component} = adaptTypeCheckBlockMetadata(
|
||||
ref,
|
||||
meta,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import {ErrorCode, ngErrorCode} from '../../diagnostics';
|
||||
import {absoluteFrom, getSourceFileOrError} from '../../file_system';
|
||||
import {runInEachFileSystem} from '../../file_system/testing';
|
||||
import {InliningMode} from '../../program_driver';
|
||||
import {OptimizeFor} from '../api';
|
||||
|
||||
import {getClass, setup, TestDeclaration} from '../testing';
|
||||
|
|
@ -171,7 +172,11 @@ runInEachFileSystem(() => {
|
|||
[
|
||||
{
|
||||
fileName,
|
||||
source: `abstract class Cmp {} // not exported, so requires inline`,
|
||||
source: `
|
||||
abstract class Cmp {
|
||||
value: string = 'hello';
|
||||
}
|
||||
`,
|
||||
templates: {'Cmp': '<div></div>'},
|
||||
},
|
||||
],
|
||||
|
|
@ -182,6 +187,75 @@ runInEachFileSystem(() => {
|
|||
expect(diags.length).toBe(1);
|
||||
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.INLINE_TCB_REQUIRED));
|
||||
});
|
||||
|
||||
it('should generate TCB in shim file when inlining is unsupported but required', () => {
|
||||
const fileName = absoluteFrom('/main.ts');
|
||||
const {program, templateTypeChecker} = setup(
|
||||
[
|
||||
{
|
||||
fileName,
|
||||
source: `abstract class Cmp {} // not exported, so requires inline`,
|
||||
templates: {'Cmp': '<div></div>'},
|
||||
},
|
||||
],
|
||||
{inliningMode: InliningMode.CopySourceToTcb},
|
||||
);
|
||||
const sf = getSourceFileOrError(program, fileName);
|
||||
const diags = templateTypeChecker.getDiagnosticsForFile(sf, OptimizeFor.WholeProgram);
|
||||
expect(diags.length).toBe(0);
|
||||
|
||||
const cmp = getClass(sf, 'Cmp');
|
||||
const block = templateTypeChecker.getTypeCheckBlock(cmp);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.getText()).toContain(`Cmp`);
|
||||
});
|
||||
|
||||
it('should filter out diagnostics from copied source in CopySourceToTcb mode', () => {
|
||||
const fileName = absoluteFrom('/main.ts');
|
||||
const {program, templateTypeChecker} = setup(
|
||||
[
|
||||
{
|
||||
fileName,
|
||||
source: `
|
||||
abstract class Cmp {
|
||||
value: string = 1; // Semantic error
|
||||
}
|
||||
`,
|
||||
templates: {'Cmp': '<div></div>'},
|
||||
},
|
||||
],
|
||||
{inliningMode: InliningMode.CopySourceToTcb},
|
||||
);
|
||||
const sf = getSourceFileOrError(program, fileName);
|
||||
const diags = templateTypeChecker.getDiagnosticsForFile(sf, OptimizeFor.WholeProgram);
|
||||
// The semantic error in the copied source should be filtered out.
|
||||
expect(diags.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not filter out template errors in CopySourceToTcb mode', () => {
|
||||
const fileName = absoluteFrom('/main.ts');
|
||||
const {program, templateTypeChecker} = setup(
|
||||
[
|
||||
{
|
||||
fileName,
|
||||
source: `
|
||||
abstract class Cmp {
|
||||
value: string = 'hello';
|
||||
}
|
||||
`,
|
||||
templates: {'Cmp': '<div>{{nonExisting}}</div>'},
|
||||
},
|
||||
],
|
||||
{inliningMode: InliningMode.CopySourceToTcb},
|
||||
);
|
||||
const sf = getSourceFileOrError(program, fileName);
|
||||
const diags = templateTypeChecker.getDiagnosticsForFile(sf, OptimizeFor.WholeProgram);
|
||||
// The template error should NOT be filtered out.
|
||||
expect(diags.length).toBe(1);
|
||||
expect(diags[0].messageText).toContain(
|
||||
"Property 'nonExisting' does not exist on type 'Cmp'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTemplateOfComponent()', () => {
|
||||
|
|
|
|||
|
|
@ -1036,8 +1036,7 @@ function detectAngularCoreVersion(
|
|||
|
||||
function createProgramDriver(project: ts.server.Project): ProgramDriver {
|
||||
return {
|
||||
// TODO: switch to CopySourceToTcb
|
||||
inliningMode: InliningMode.Error,
|
||||
inliningMode: InliningMode.CopySourceToTcb,
|
||||
supportsInlineOperations: false,
|
||||
getProgram(): ts.Program {
|
||||
const program = project.getLanguageService().getProgram();
|
||||
|
|
|
|||
|
|
@ -1264,6 +1264,148 @@ describe('quick info', () => {
|
|||
expectedDisplayString: '(reference) ref: TestDirective',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get quick info for a component with non-exported generic bound requiring external copy', () => {
|
||||
project = env.addProject('test', {
|
||||
'app.ts': `
|
||||
import {Component, NgModule} from '@angular/core';
|
||||
|
||||
interface PrivateInterface {
|
||||
title: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'some-cmp',
|
||||
templateUrl: './app.html',
|
||||
standalone: false,
|
||||
})
|
||||
export class SomeCmp<T extends PrivateInterface> {
|
||||
title = 'Hello';
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
declarations: [SomeCmp],
|
||||
})
|
||||
export class AppModule {}
|
||||
`,
|
||||
'app.html': ``,
|
||||
});
|
||||
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div>{{tit¦le}}</div>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) SomeCmp<T extends PrivateInterface>.title: string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get quick info for a non-exported standalone component', () => {
|
||||
project = env.addProject('test', {
|
||||
'app.ts': `
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'some-cmp',
|
||||
templateUrl: './app.html',
|
||||
standalone: true,
|
||||
})
|
||||
class SomeCmp {
|
||||
title = 'Hello';
|
||||
}
|
||||
`,
|
||||
'app.html': ``,
|
||||
});
|
||||
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div>{{tit¦le}}</div>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) SomeCmp.title: string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get quick info for a standalone component defined in a closure', () => {
|
||||
project = env.addProject('test', {
|
||||
'app.ts': `
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
(function() {
|
||||
@Component({
|
||||
selector: 'some-cmp',
|
||||
templateUrl: './app.html',
|
||||
standalone: true,
|
||||
})
|
||||
class TestComponent {
|
||||
value = 0;
|
||||
}
|
||||
})();
|
||||
`,
|
||||
'app.html': ``,
|
||||
});
|
||||
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div>{{val¦ue}}</div>`,
|
||||
expectedSpanText: 'value',
|
||||
expectedDisplayString: '(property) TestComponent.value: number',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get quick info for a component with constrained generic types requiring inline TCB', () => {
|
||||
project = env.addProject('test', {
|
||||
'app.ts': `
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
interface InternalBound {}
|
||||
|
||||
@Component({
|
||||
selector: 'some-cmp',
|
||||
templateUrl: './app.html',
|
||||
standalone: true,
|
||||
})
|
||||
export class SomeCmp<T extends InternalBound> {
|
||||
title = 'Hello';
|
||||
}
|
||||
`,
|
||||
'app.html': ``,
|
||||
});
|
||||
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div>{{tit¦le}}</div>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) SomeCmp<T extends InternalBound>.title: string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get quick info when using a non-exported pipe requiring inline TCB', () => {
|
||||
project = env.addProject('test', {
|
||||
'app.ts': `
|
||||
import {Component, Pipe, PipeTransform} from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'internalPipe',
|
||||
standalone: true,
|
||||
})
|
||||
class InternalPipe implements PipeTransform {
|
||||
transform(value: string): string { return value; }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'some-cmp',
|
||||
templateUrl: './app.html',
|
||||
standalone: true,
|
||||
imports: [InternalPipe],
|
||||
})
|
||||
export class SomeCmp {
|
||||
title = 'Hello';
|
||||
}
|
||||
`,
|
||||
'app.html': ``,
|
||||
});
|
||||
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div>{{tit¦le | internalPipe}}</div>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) SomeCmp.title: string',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectQuickInfo({
|
||||
|
|
|
|||
Loading…
Reference in a new issue