fix(compiler-cli): generic types not filled out correctly in type check block

Fixes a regression caused by the recent TCB changes where we moved the type parameter processing earlier in the pipeline and stopped properly accounting for the `TcbGenericContextBehavior`.

Fixes #67704.

(cherry picked from commit 9769560da7)
This commit is contained in:
Kristiyan Kostadinov 2026-03-16 20:52:44 +01:00 committed by Matthew Beck
parent ca328eab8f
commit 67e0ba7e03
8 changed files with 113 additions and 88 deletions

View file

@ -89,7 +89,7 @@ export interface TcbDirectiveMetadata {
typeParameters: TcbTypeParameter[] | null;
inputs: ClassPropertyMapping<TcbInputMapping>;
outputs: ClassPropertyMapping;
hasRequiresInlineTypeCtor: boolean;
requiresInlineTypeCtor: boolean;
ngTemplateGuards: TemplateGuardMeta[];
hasNgTemplateContextGuard: boolean;
hasNgFieldDirective: boolean;
@ -105,6 +105,7 @@ export interface TcbDirectiveMetadata {
export interface TcbComponentMetadata {
ref: TcbReferenceMetadata;
typeParameters: TcbTypeParameter[] | null;
typeArguments: string[] | null;
}
export interface TcbTypeCheckBlockMetadata {

View file

@ -671,7 +671,12 @@ class InlineTcbOp implements Op {
const env = new Environment(this.config, im, refEmitter, this.reflector, sf);
const fnName = `_tcb_${this.ref.node.pos}`;
const {tcbMeta, component} = adaptTypeCheckBlockMetadata(this.ref, this.meta, env);
const {tcbMeta, component} = adaptTypeCheckBlockMetadata(
this.ref,
this.meta,
env,
TcbGenericContextBehavior.CopyClassNodes,
);
// Inline TCBs should copy any generic type parameter nodes directly, as the TCB code is
// inlined into the class in a context where that will always be legal.
@ -682,7 +687,6 @@ class InlineTcbOp implements Op {
tcbMeta,
this.domSchemaChecker,
this.oobRecorder,
TcbGenericContextBehavior.CopyClassNodes,
);
return fn;

View file

@ -69,7 +69,7 @@ export class Environment extends ReferenceEmitEnvironment {
return new TcbExpr(this.typeCtors.get(key)!);
}
if (dir.hasRequiresInlineTypeCtor) {
if (dir.requiresInlineTypeCtor) {
// The constructor has already been created inline, we just need to construct a reference to
// it.
const typeCtorExpr = `${this.referenceTcbValue(dir.ref).print()}.ngTypeCtor`;

View file

@ -774,7 +774,7 @@ export class Scope {
// The most common case is that when a directive is not generic, we use the normal
// `TcbNonDirectiveTypeOp`.
return new TcbNonGenericDirectiveTypeOp(this.tcb, this, node, dir);
} else if (!dir.hasRequiresInlineTypeCtor || this.tcb.env.config.useInlineTypeConstructors) {
} else if (!dir.requiresInlineTypeCtor || this.tcb.env.config.useInlineTypeConstructors) {
// For generic directives, we use a type constructor to infer types. If a directive requires
// an inline type constructor, then inlining must be available to use the
// `TcbDirectiveCtorOp`. If not we, we fallback to using `any` see below.

View file

@ -15,6 +15,7 @@ import {
TcbInputMapping,
TcbPipeMetadata,
TypeCheckableDirectiveMeta,
TcbTypeParameter,
} from '../api';
import {Environment} from './environment';
import {ImportFlags, ReferenceEmitKind, Reference} from '../../imports';
@ -35,6 +36,7 @@ import {generateTcbTypeParameters} from './tcb_util';
import {TypeParameterEmitter} from './type_parameter_emitter';
import {ClassDeclaration} from '../../reflection';
import ts from 'typescript';
import {TcbGenericContextBehavior} from './ops/context';
/**
* Adapts the compiler's `TypeCheckBlockMetadata` (which includes full TS AST nodes)
@ -44,8 +46,11 @@ export function adaptTypeCheckBlockMetadata(
ref: Reference<ClassDeclaration<ts.ClassDeclaration>>,
meta: TypeCheckBlockMetadata,
env: Environment,
genericContextBehavior: TcbGenericContextBehavior,
): {tcbMeta: TcbTypeCheckBlockMetadata; component: TcbComponentMetadata} {
const refCache = new Map<Reference<ClassDeclaration>, TcbReferenceMetadata>();
const dirCache = new Map<TypeCheckableDirectiveMeta, TcbDirectiveMetadata>();
const extractRef = (ref: Reference<ClassDeclaration>) => {
if (refCache.has(ref)) {
return refCache.get(ref)!;
@ -55,8 +60,6 @@ export function adaptTypeCheckBlockMetadata(
return result;
};
const dirCache = new Map<TypeCheckableDirectiveMeta, TcbDirectiveMetadata>();
const convertDir = (dir: TypeCheckableDirectiveMeta): TcbDirectiveMetadata => {
if (dirCache.has(dir)) return dirCache.get(dir)!;
@ -110,45 +113,33 @@ export function adaptTypeCheckBlockMetadata(
ref: extractRef(dir.ref as Reference<ClassDeclaration>),
isGeneric: dir.isGeneric,
typeParameters: (() => {
const node = dir.ref.node as ClassDeclaration<ts.ClassDeclaration>;
if (!node.typeParameters) {
return null;
}
const emitter = new TypeParameterEmitter(node.typeParameters, env.reflector);
let emitted: ts.TypeParameterDeclaration[] | undefined;
if (!emitter.canEmit((ref) => env.canReferenceType(ref))) {
emitted = [...node.typeParameters] as ts.TypeParameterDeclaration[];
} else {
emitted = emitter.emit((ref) => env.referenceType(ref));
}
return generateTcbTypeParameters(emitted || [], env.contextFile);
})(),
hasRequiresInlineTypeCtor: requiresInlineTypeCtor(
requiresInlineTypeCtor: requiresInlineTypeCtor(
dir.ref.node as ClassDeclaration<ts.ClassDeclaration>,
env.reflector,
env,
),
...adaptGenerics(
dir.ref.node as ClassDeclaration<ts.ClassDeclaration>,
env,
TcbGenericContextBehavior.UseEmitter,
),
};
dirCache.set(dir, tcbDir);
return tcbDir;
};
const originalBoundTarget = meta.boundTarget.target;
const adaptedBoundTarget: BoundTarget<TcbDirectiveMetadata> = {
target: (() => {
const originalTarget = meta.boundTarget.target;
return {
template: originalTarget.template,
host: originalTarget.host
? {
node: originalTarget.host.node,
directives: originalTarget.host.directives.map(convertDir),
}
: undefined,
};
})(),
target: {
template: originalBoundTarget.template,
host: originalBoundTarget.host
? {
node: originalBoundTarget.host.node,
directives: originalBoundTarget.host.directives.map(convertDir),
}
: undefined,
},
getUsedDirectives: () => meta.boundTarget.getUsedDirectives().map(convertDir),
getEagerlyUsedDirectives: () => meta.boundTarget.getEagerlyUsedDirectives().map(convertDir),
getUsedPipes: () => meta.boundTarget.getUsedPipes(),
@ -208,25 +199,59 @@ export function adaptTypeCheckBlockMetadata(
isStandalone: meta.isStandalone,
preserveWhitespaces: meta.preserveWhitespaces,
},
component: (() => {
return {
ref: extractRef(ref as Reference<ClassDeclaration>),
typeParameters: (() => {
if (!ref.node.typeParameters) return null;
const emitter = new TypeParameterEmitter(ref.node.typeParameters, env.reflector);
let emitted: ts.TypeParameterDeclaration[] | undefined;
if (!emitter.canEmit((r) => env.canReferenceType(r))) {
emitted = [...ref.node.typeParameters] as ts.TypeParameterDeclaration[];
} else {
emitted = emitter.emit((r) => env.referenceType(r));
}
return generateTcbTypeParameters(emitted || [], env.contextFile);
})(),
};
})(),
component: {
ref: extractRef(ref as Reference<ClassDeclaration>),
...adaptGenerics(
ref.node,
env,
env.config.useContextGenericType
? genericContextBehavior
: TcbGenericContextBehavior.FallbackToAny,
),
},
};
}
function adaptGenerics(
node: ClassDeclaration<ts.ClassDeclaration>,
env: Environment,
genericContextBehavior: TcbGenericContextBehavior,
): {
typeParameters: TcbTypeParameter[] | null;
typeArguments: string[] | null;
} {
let typeParameters: TcbTypeParameter[] | null;
let typeArguments: string[] | null;
if (node.typeParameters !== undefined && node.typeParameters.length > 0) {
switch (genericContextBehavior) {
case TcbGenericContextBehavior.UseEmitter:
const emitter = new TypeParameterEmitter(node.typeParameters, env.reflector);
const emittedParams = emitter.canEmit((r) => env.canReferenceType(r))
? emitter.emit((typeRef) => env.referenceType(typeRef))
: undefined;
typeParameters = generateTcbTypeParameters(
emittedParams || node.typeParameters,
env.contextFile,
);
typeArguments = typeParameters.map((param) => param.name);
break;
case TcbGenericContextBehavior.CopyClassNodes:
typeParameters = generateTcbTypeParameters(node.typeParameters, env.contextFile);
typeArguments = typeParameters.map((param) => param.name);
break;
case TcbGenericContextBehavior.FallbackToAny:
typeParameters = generateTcbTypeParameters(node.typeParameters, env.contextFile);
typeArguments = new Array<string>(node.typeParameters.length).fill('any');
break;
}
} else {
typeParameters = typeArguments = null;
}
return {typeParameters, typeArguments};
}
function extractReferenceMetadata(
ref: Reference<ClassDeclaration>,
env: Environment,

View file

@ -6,13 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/
import ts from 'typescript';
import {TcbComponentMetadata, TcbTypeCheckBlockMetadata, TcbTypeParameter} from '../api';
import {TcbComponentMetadata, TcbTypeCheckBlockMetadata} from '../api';
import {DomSchemaChecker} from './dom';
import {Environment} from './environment';
import {OutOfBandDiagnosticRecorder} from './oob';
import {createHostBindingsBlockGuard} from './host_bindings';
import {Context, TcbGenericContextBehavior} from './ops/context';
import {Context} from './ops/context';
import {Scope} from './ops/scope';
import {getStatementsBlock} from './ops/codegen';
@ -47,7 +46,6 @@ export function generateTypeCheckBlock(
meta: TcbTypeCheckBlockMetadata,
domSchemaChecker: DomSchemaChecker,
oobRecorder: OutOfBandDiagnosticRecorder,
genericContextBehavior: TcbGenericContextBehavior,
): string {
const tcb = new Context(
env,
@ -61,42 +59,14 @@ export function generateTypeCheckBlock(
meta.preserveWhitespaces,
);
const ctxRawType = env.referenceTcbValue(component.ref);
let typeParameters: TcbTypeParameter[] | undefined = undefined;
let typeArguments: string[] | undefined = undefined;
if (component.typeParameters !== undefined) {
if (!env.config.useContextGenericType) {
genericContextBehavior = TcbGenericContextBehavior.FallbackToAny;
}
switch (genericContextBehavior) {
case TcbGenericContextBehavior.UseEmitter:
// Guaranteed to emit type parameters since we checked that the class has them above.
const emittedParams = component.typeParameters || [];
typeParameters = emittedParams;
typeArguments = typeParameters!.map((param) => param.name);
break;
case TcbGenericContextBehavior.CopyClassNodes:
const copiedParams = component.typeParameters ? [...component.typeParameters] : [];
typeParameters = copiedParams;
typeArguments = typeParameters!.map((param) => param.name);
break;
case TcbGenericContextBehavior.FallbackToAny:
typeArguments = Array.from({length: component.typeParameters?.length ?? 0}).map(
() => 'any',
);
break;
}
}
const {typeParameters, typeArguments} = component;
const typeParamsStr =
typeParameters === undefined || typeParameters.length === 0
!env.config.useContextGenericType || typeParameters === null || typeParameters.length === 0
? ''
: `<${typeParameters.map((p) => p.representation).join(', ')}>`;
const typeArgsStr =
typeArguments === undefined || typeArguments.length === 0
? ''
: `<${typeArguments.join(', ')}>`;
typeArguments === null || typeArguments.length === 0 ? '' : `<${typeArguments.join(', ')}>`;
const thisParamStr = `this: ${ctxRawType.print()}${typeArgsStr}`;
const statements: string[] = [];

View file

@ -70,7 +70,12 @@ export class TypeCheckFile extends Environment {
genericContextBehavior: TcbGenericContextBehavior,
): void {
const fnId = `_tcb${this.nextTcbId++}`;
const {tcbMeta, component} = adaptTypeCheckBlockMetadata(ref, meta, this);
const {tcbMeta, component} = adaptTypeCheckBlockMetadata(
ref,
meta,
this,
genericContextBehavior,
);
const fn = generateTypeCheckBlock(
this,
component,
@ -78,7 +83,6 @@ export class TypeCheckFile extends Environment {
tcbMeta,
domSchemaChecker,
oobRecorder,
genericContextBehavior,
);
this.tcbStatements.push(fn);
}

View file

@ -10788,6 +10788,27 @@ runInEachFileSystem((os: string) => {
expect(codes).toEqual([ngErrorCode(ErrorCode.NGMODULE_BOOTSTRAP_IS_STANDALONE)]);
});
it('should compile a component with a complex generic', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
@Component({
selector: 'app-root',
template: '',
})
export class App<
T extends object = object,
TOptions extends { [K in keyof T]?: T[K] } = object
> {}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
describe('InjectorDef emit optimizations for standalone', () => {
it('should not filter components out of NgModule.imports', () => {
env.write(