Skip to content

Commit 30c950f

Browse files
committed
refactor(compiler-cli): Fix regressions caused by ts.typechecker removal
removing ts.typechecker in a prior refactor caused some regressions, particularly when multiple directives appear on a single elemnt. this is now addressed by using an id for directives and storing that in the tcb comment
1 parent e95d846 commit 30c950f

9 files changed

Lines changed: 155 additions & 137 deletions

File tree

packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,39 @@ export function hasExpressionIdentifier(
182182
return false;
183183
}
184184
const commentText = sourceFile.text.substring(pos + 2, end - 2);
185-
return commentText === `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}`;
185+
const prefix = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}`;
186+
return commentText === prefix || commentText.startsWith(prefix + ':');
186187
}) || false
187188
);
188189
}
190+
191+
export function readDirectiveIdFromComment(
192+
sourceFile: ts.SourceFile,
193+
node: ts.Node,
194+
): number | null {
195+
let id: number | null = null;
196+
ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => {
197+
if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
198+
return;
199+
}
200+
const commentText = sourceFile.text.substring(pos + 2, end - 2);
201+
const prefix = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${ExpressionIdentifier.DIRECTIVE}:`;
202+
const hostPrefix = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${ExpressionIdentifier.HOST_DIRECTIVE}:`;
203+
204+
let matchedPrefix: string | null = null;
205+
if (commentText.startsWith(prefix)) {
206+
matchedPrefix = prefix;
207+
} else if (commentText.startsWith(hostPrefix)) {
208+
matchedPrefix = hostPrefix;
209+
}
210+
211+
if (matchedPrefix !== null) {
212+
const idStr = commentText.substring(matchedPrefix.length);
213+
const parsed = parseInt(idStr, 10);
214+
if (!isNaN(parsed)) {
215+
id = parsed;
216+
}
217+
}
218+
});
219+
return id;
220+
}

packages/compiler-cli/src/ngtsc/typecheck/src/ops/codegen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ export class TcbExpr {
8484
* Tags the expression with an identifier.
8585
* @param identifier Identifier to apply to the expression.
8686
*/
87-
addExpressionIdentifier(identifier: ExpressionIdentifier): this {
88-
this.identifierComment = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}`;
87+
addExpressionIdentifier(identifier: ExpressionIdentifier, id?: number): this {
88+
this.identifierComment = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}${id !== undefined ? `:${id}` : ''}`;
8989
return this;
9090
}
9191

packages/compiler-cli/src/ngtsc/typecheck/src/ops/directive_constructor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class TcbDirectiveCtorOp extends TcbOp {
3737
private node: DirectiveOwner,
3838
private dir: TcbDirectiveMetadata,
3939
private customFormControlType: CustomFormControlType | null,
40+
private directiveIndex?: number,
4041
) {
4142
super();
4243
}
@@ -78,7 +79,7 @@ export class TcbDirectiveCtorOp extends TcbOp {
7879
this.dir.matchSource === MatchSource.HostDirective
7980
? ExpressionIdentifier.HOST_DIRECTIVE
8081
: ExpressionIdentifier.DIRECTIVE;
81-
id.addExpressionIdentifier(identifier).addParseSpanInfo(span);
82+
id.addExpressionIdentifier(identifier, this.directiveIndex).addParseSpanInfo(span);
8283

8384
for (const attr of boundAttrs) {
8485
// Skip text attributes if configured to do so.

packages/compiler-cli/src/ngtsc/typecheck/src/ops/directive_type.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export abstract class TcbDirectiveTypeOpBase extends TcbOp {
2424
protected scope: Scope,
2525
protected node: DirectiveOwner,
2626
protected dir: TcbDirectiveMetadata,
27+
protected directiveIndex?: number,
2728
) {
2829
super();
2930
}
@@ -64,7 +65,7 @@ export abstract class TcbDirectiveTypeOpBase extends TcbOp {
6465
? ExpressionIdentifier.HOST_DIRECTIVE
6566
: ExpressionIdentifier.DIRECTIVE;
6667
const id = new TcbExpr(this.tcb.allocateId())
67-
.addExpressionIdentifier(identifier)
68+
.addExpressionIdentifier(identifier, this.directiveIndex)
6869
.addParseSpanInfo(span);
6970
this.scope.addStatement(declareVariable(id, type));
7071
return id;

packages/compiler-cli/src/ngtsc/typecheck/src/ops/scope.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -603,8 +603,9 @@ export class Scope {
603603
}
604604

605605
const dirMap = new Map<TcbDirectiveMetadata, number>();
606-
for (const dir of directives) {
607-
this.appendDirectiveInputs(dir, node, dirMap, directives);
606+
for (let i = 0; i < directives.length; i++) {
607+
const dir = directives[i];
608+
this.appendDirectiveInputs(dir, node, dirMap, directives, i);
608609
}
609610
this.directiveOpMap.set(node, dirMap);
610611

@@ -680,8 +681,9 @@ export class Scope {
680681

681682
if (directives !== null && directives.length > 0) {
682683
const dirMap = new Map<TcbDirectiveMetadata, number>();
683-
for (const dir of directives) {
684-
this.appendDirectiveInputs(dir, node, dirMap, directives);
684+
for (let i = 0; i < directives.length; i++) {
685+
const dir = directives[i];
686+
this.appendDirectiveInputs(dir, node, dirMap, directives, i);
685687

686688
for (const propertyName of dir.inputs.propertyNames) {
687689
claimedInputs.add(propertyName);
@@ -750,11 +752,12 @@ export class Scope {
750752
node: TmplAstElement | TmplAstTemplate | TmplAstComponent | TmplAstDirective,
751753
dirMap: Map<TcbDirectiveMetadata, number>,
752754
allDirectiveMatches: TcbDirectiveMetadata[],
755+
directiveIndex?: number,
753756
): void {
754757
const nodeIsFormControl = isFormControl(allDirectiveMatches);
755758
const customFormControlType = nodeIsFormControl ? getCustomFieldDirectiveType(dir) : null;
756759

757-
const directiveOp = this.getDirectiveOp(dir, node, customFormControlType);
760+
const directiveOp = this.getDirectiveOp(dir, node, customFormControlType, directiveIndex);
758761
const dirIndex = this.opQueue.push(directiveOp) - 1;
759762
dirMap.set(dir, dirIndex);
760763

@@ -779,21 +782,22 @@ export class Scope {
779782
dir: TcbDirectiveMetadata,
780783
node: DirectiveOwner,
781784
customFieldType: CustomFormControlType | null,
785+
directiveIndex?: number,
782786
): TcbOp {
783787
if (!dir.isGeneric) {
784788
// The most common case is that when a directive is not generic, we use the normal
785789
// `TcbNonDirectiveTypeOp`.
786-
return new TcbNonGenericDirectiveTypeOp(this.tcb, this, node, dir);
790+
return new TcbNonGenericDirectiveTypeOp(this.tcb, this, node, dir, directiveIndex);
787791
} else if (!dir.requiresInlineTypeCtor || this.tcb.env.config.useInlineTypeConstructors) {
788792
// For generic directives, we use a type constructor to infer types. If a directive requires
789793
// an inline type constructor, then inlining must be available to use the
790794
// `TcbDirectiveCtorOp`. If not we, we fallback to using `any` – see below.
791-
return new TcbDirectiveCtorOp(this.tcb, this, node, dir, customFieldType);
795+
return new TcbDirectiveCtorOp(this.tcb, this, node, dir, customFieldType, directiveIndex);
792796
}
793797

794798
// If inlining is not available, then we give up on inferring the generic params, and use
795799
// `any` type for the directive's generic parameters.
796-
return new TcbGenericDirectiveTypeWithAnyParamsOp(this.tcb, this, node, dir);
800+
return new TcbGenericDirectiveTypeWithAnyParamsOp(this.tcb, this, node, dir, directiveIndex);
797801
}
798802

799803
private appendSelectorlessDirectives(

packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts

Lines changed: 37 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
findAllMatchingNodes,
6767
findFirstMatchingNode,
6868
hasExpressionIdentifier,
69+
readDirectiveIdFromComment,
6970
} from './comments';
7071
import {TypeCheckData} from './context';
7172
import {isAccessExpression, isDirectiveDeclaration} from './ts_util';
@@ -207,36 +208,52 @@ export class SymbolBuilder {
207208
templateNode: TmplAstElement | TmplAstTemplate | TmplAstComponent | TmplAstDirective,
208209
): DirectiveSymbol[] {
209210
const elementSourceSpan = templateNode.startSourceSpan ?? templateNode.sourceSpan;
210-
const nodes = findAllMatchingNodes(this.typeCheckBlock, {
211-
withSpan: elementSourceSpan,
212-
filter: isDirectiveDeclaration,
213-
});
214-
const symbols: DirectiveSymbol[] = [];
215-
const seenDirectives = new Set<ts.ClassDeclaration>();
211+
const boundDirectives = this.typeCheckData.boundTarget.getDirectivesOfNode(templateNode) ?? [];
216212

217-
let boundDirectives = this.typeCheckData.boundTarget.getDirectivesOfNode(templateNode) ?? [];
213+
let symbols = this.getDirectiveSymbolsForDirectives(boundDirectives, elementSourceSpan);
218214

219215
// 'getDirectivesOfNode' will not return the directives intended for an element
220216
// on a microsyntax template, for example '<div *ngFor="let user of users;" dir>',
221217
// the 'dir' will be skipped, but it's needed in language service.
222218
if (!(templateNode instanceof TmplAstDirective)) {
223-
const firstChild = templateNode.children?.[0];
224-
if (firstChild instanceof TmplAstElement) {
219+
const firstChild = templateNode.children.find(
220+
(c): c is TmplAstElement => c instanceof TmplAstElement,
221+
);
222+
if (firstChild !== undefined) {
225223
const isMicrosyntaxTemplate =
226224
templateNode instanceof TmplAstTemplate &&
227225
sourceSpanEqual(firstChild.sourceSpan, templateNode.sourceSpan);
228226
if (isMicrosyntaxTemplate) {
229227
const firstChildDirectives =
230228
this.typeCheckData.boundTarget.getDirectivesOfNode(firstChild);
231-
if (firstChildDirectives !== null && boundDirectives.length > 0) {
232-
boundDirectives = boundDirectives.concat(firstChildDirectives);
233-
} else if (firstChildDirectives !== null) {
234-
boundDirectives = firstChildDirectives;
229+
if (firstChildDirectives !== null) {
230+
const childSymbols = this.getDirectiveSymbolsForDirectives(
231+
firstChildDirectives,
232+
elementSourceSpan,
233+
);
234+
// Merge symbols, avoiding duplicates
235+
for (const symbol of childSymbols) {
236+
if (!symbols.some((s) => s.ref.node === symbol.ref.node)) {
237+
symbols.push(symbol);
238+
}
239+
}
235240
}
236241
}
237242
}
238243
}
239244

245+
return symbols;
246+
}
247+
248+
private getDirectiveSymbolsForDirectives(
249+
boundDirectives: TypeCheckableDirectiveMeta[],
250+
span: ParseSourceSpan,
251+
): DirectiveSymbol[] {
252+
const nodes = findAllMatchingNodes(this.typeCheckBlock, {
253+
withSpan: span,
254+
filter: isDirectiveDeclaration,
255+
});
256+
240257
const hostDirectiveMap = new Map<ts.Node, HostDirectiveMeta>();
241258
for (const d of boundDirectives) {
242259
if (d.hostDirectives) {
@@ -248,33 +265,14 @@ export class SymbolBuilder {
248265
}
249266
}
250267

251-
for (let i = 0; i < nodes.length; i++) {
252-
const node = nodes[i];
253-
254-
let nodeName: string | null = null;
255-
let typeNode = ts.isTypeNode(node)
256-
? node
257-
: ts.isIdentifier(node) && node.parent && ts.isVariableDeclaration(node.parent)
258-
? node.parent.type
259-
: null;
260-
if (typeNode && ts.isTypeReferenceNode(typeNode)) {
261-
const typeName = typeNode.typeName;
262-
nodeName = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
263-
} else if (typeNode && ts.isIntersectionTypeNode(typeNode)) {
264-
const first = typeNode.types[0];
265-
if (ts.isTypeReferenceNode(first)) {
266-
const typeName = first.typeName;
267-
nodeName = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
268-
}
269-
}
270-
271-
// Match by name with index fallback
272-
let meta = boundDirectives[i];
273-
if (nodeName) {
274-
meta =
275-
boundDirectives.find((m) => m.ref.node.name && m.ref.node.name.text === nodeName) ?? meta;
276-
}
268+
const symbols: DirectiveSymbol[] = [];
269+
const seenDirectives = new Set<ts.ClassDeclaration>();
270+
const sf = this.typeCheckBlock.getSourceFile();
277271

272+
for (const node of nodes) {
273+
const id = readDirectiveIdFromComment(sf, node);
274+
if (id === null) continue;
275+
const meta = boundDirectives[id];
278276
if (!meta) continue;
279277

280278
const declaration = meta.ref.node as unknown as ts.ClassDeclaration;
@@ -316,85 +314,9 @@ export class SymbolBuilder {
316314
}
317315
}
318316

319-
// Sort to ensure host directives appear first (matching test expectations)
320-
symbols.sort((a, b) => {
321-
if (a.matchSource === MatchSource.HostDirective && b.matchSource === MatchSource.Selector) {
322-
return -1;
323-
}
324-
if (a.matchSource === MatchSource.Selector && b.matchSource === MatchSource.HostDirective) {
325-
return 1;
326-
}
327-
return 0;
328-
});
329-
330317
return symbols;
331318
}
332319

333-
private getDirectiveMeta(
334-
host: TmplAstTemplate | TmplAstElement | TmplAstComponent | TmplAstDirective,
335-
directiveDeclaration: ts.ClassDeclaration,
336-
): TypeCheckableDirectiveMeta | null {
337-
let directives = this.typeCheckData.boundTarget.getDirectivesOfNode(host);
338-
339-
// `getDirectivesOfNode` will not return the directives intended for an element
340-
// on a microsyntax template, for example `<div *ngFor="let user of users;" dir>`,
341-
// the `dir` will be skipped, but it's needed in language service.
342-
if (!(host instanceof TmplAstDirective)) {
343-
const firstChild = host.children[0];
344-
if (firstChild instanceof TmplAstElement) {
345-
const isMicrosyntaxTemplate =
346-
host instanceof TmplAstTemplate &&
347-
sourceSpanEqual(firstChild.sourceSpan, host.sourceSpan);
348-
if (isMicrosyntaxTemplate) {
349-
const firstChildDirectives =
350-
this.typeCheckData.boundTarget.getDirectivesOfNode(firstChild);
351-
if (firstChildDirectives !== null && directives !== null) {
352-
directives = directives.concat(firstChildDirectives);
353-
} else {
354-
directives = directives ?? firstChildDirectives;
355-
}
356-
}
357-
}
358-
}
359-
if (directives === null) {
360-
return null;
361-
}
362-
363-
const directive = directives.find((m) =>
364-
isSameDirectiveDeclaration(m.ref.node, directiveDeclaration),
365-
);
366-
if (directive) {
367-
return directive;
368-
}
369-
370-
const originalFile = (directiveDeclaration.getSourceFile() as MaybeSourceFileWithOriginalFile)[
371-
NgOriginalFile
372-
];
373-
374-
if (originalFile !== undefined) {
375-
// This is a preliminary check ahead of a more expensive search
376-
const hasPotentialCandidate = directives.find(
377-
(m) => m.ref.node.name.text === directiveDeclaration.name?.text,
378-
);
379-
380-
if (hasPotentialCandidate) {
381-
// In case the TCB has been inlined,
382-
// We will look for a matching class
383-
// If we find one, we look for it in the directives array
384-
const classWithSameName = findMatchingDirective(originalFile, directiveDeclaration);
385-
if (classWithSameName !== null) {
386-
return (
387-
directives.find((m) => isSameDirectiveDeclaration(m.ref.node, classWithSameName)) ??
388-
null
389-
);
390-
}
391-
}
392-
}
393-
394-
// Really nothing was found
395-
return null;
396-
}
397-
398320
private getDirectiveModule(declaration: ts.ClassDeclaration): ClassDeclaration | null {
399321
const scope = this.componentScopeReader.getScopeForComponent(declaration as ClassDeclaration);
400322
if (scope === null || scope.kind !== ComponentScopeKind.NgModule) {

0 commit comments

Comments
 (0)